diff options
| author | soryu <soryu@soryu.co> | 2026-02-05 23:42:48 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-05 23:42:48 +0000 |
| commit | 88a4f15ce1310f8ee8693835be14aa5280233f17 (patch) | |
| tree | 5c1a0417e02071d2198d13478ffa85533b19f891 | |
| parent | f1a50b80f3969d150bd1c31edde0aff05369157e (diff) | |
| download | soryu-88a4f15ce1310f8ee8693835be14aa5280233f17.tar.gz soryu-88a4f15ce1310f8ee8693835be14aa5280233f17.zip | |
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 <noreply@anthropic.com>
39 files changed, 9115 insertions, 4153 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 4f6cf32..ece07e4 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -10,6 +10,7 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, + { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Chains", href: "/chains", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts new file mode 100644 index 0000000..6e1654f --- /dev/null +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -0,0 +1,298 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { + listDirectives, + getDirective, + createDirective, + updateDirective, + archiveDirective, + startDirective, + pauseDirective, + resumeDirective, + stopDirective, + getDirectiveGraph, + subscribeToDirectiveEvents, + type DirectiveSummary, + type DirectiveWithProgress, + type DirectiveGraphResponse, + type DirectiveStatus, + type DirectiveEvent, + type CreateDirectiveRequest, + type UpdateDirectiveRequest, + type StartDirectiveResponse, +} from "../lib/api"; + +interface UseDirectivesResult { + directives: DirectiveSummary[]; + loading: boolean; + error: string | null; + refresh: () => Promise<void>; + createNewDirective: (req: CreateDirectiveRequest) => Promise<DirectiveWithProgress | null>; + updateExistingDirective: ( + directiveId: string, + req: UpdateDirectiveRequest + ) => Promise<DirectiveWithProgress | null>; + archiveExistingDirective: (directiveId: string) => Promise<boolean>; + getDirectiveById: (directiveId: string) => Promise<DirectiveWithProgress | null>; + getGraph: (directiveId: string) => Promise<DirectiveGraphResponse | null>; + start: (directiveId: string) => Promise<StartDirectiveResponse | null>; + pause: (directiveId: string) => Promise<boolean>; + resume: (directiveId: string) => Promise<boolean>; + stop: (directiveId: string) => Promise<boolean>; +} + +export function useDirectives(statusFilter?: DirectiveStatus): UseDirectivesResult { + const [directives, setDirectives] = useState<DirectiveSummary[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + const fetchDirectives = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await listDirectives(statusFilter); + setDirectives(response.directives); + } catch (err) { + console.error("Failed to fetch directives:", err); + setError(err instanceof Error ? err.message : "Failed to fetch directives"); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + fetchDirectives(); + }, [fetchDirectives]); + + const createNewDirective = useCallback( + async (req: CreateDirectiveRequest): Promise<DirectiveWithProgress | null> => { + try { + const directive = await createDirective(req); + // Refresh the list + await fetchDirectives(); + // Return the full directive with progress + return await getDirective(directive.id); + } catch (err) { + console.error("Failed to create directive:", err); + setError(err instanceof Error ? err.message : "Failed to create directive"); + return null; + } + }, + [fetchDirectives] + ); + + const updateExistingDirective = useCallback( + async ( + directiveId: string, + req: UpdateDirectiveRequest + ): Promise<DirectiveWithProgress | null> => { + try { + await updateDirective(directiveId, req); + // Refresh the list + await fetchDirectives(); + // Return the updated directive + return await getDirective(directiveId); + } catch (err) { + console.error("Failed to update directive:", err); + setError(err instanceof Error ? err.message : "Failed to update directive"); + return null; + } + }, + [fetchDirectives] + ); + + const archiveExistingDirective = useCallback( + async (directiveId: string): Promise<boolean> => { + try { + await archiveDirective(directiveId); + // Refresh the list + await fetchDirectives(); + return true; + } catch (err) { + console.error("Failed to archive directive:", err); + setError(err instanceof Error ? err.message : "Failed to archive directive"); + return false; + } + }, + [fetchDirectives] + ); + + const getDirectiveById = useCallback( + async (directiveId: string): Promise<DirectiveWithProgress | null> => { + try { + return await getDirective(directiveId); + } catch (err) { + console.error("Failed to get directive:", err); + setError(err instanceof Error ? err.message : "Failed to get directive"); + return null; + } + }, + [] + ); + + const getGraph = useCallback( + async (directiveId: string): Promise<DirectiveGraphResponse | null> => { + try { + return await getDirectiveGraph(directiveId); + } catch (err) { + console.error("Failed to get directive graph:", err); + setError(err instanceof Error ? err.message : "Failed to get directive graph"); + return null; + } + }, + [] + ); + + const start = useCallback( + async (directiveId: string): Promise<StartDirectiveResponse | null> => { + try { + const response = await startDirective(directiveId); + await fetchDirectives(); + return response; + } catch (err) { + console.error("Failed to start directive:", err); + setError(err instanceof Error ? err.message : "Failed to start directive"); + return null; + } + }, + [fetchDirectives] + ); + + const pause = useCallback( + async (directiveId: string): Promise<boolean> => { + try { + await pauseDirective(directiveId); + await fetchDirectives(); + return true; + } catch (err) { + console.error("Failed to pause directive:", err); + setError(err instanceof Error ? err.message : "Failed to pause directive"); + return false; + } + }, + [fetchDirectives] + ); + + const resume = useCallback( + async (directiveId: string): Promise<boolean> => { + try { + await resumeDirective(directiveId); + await fetchDirectives(); + return true; + } catch (err) { + console.error("Failed to resume directive:", err); + setError(err instanceof Error ? err.message : "Failed to resume directive"); + return false; + } + }, + [fetchDirectives] + ); + + const stop = useCallback( + async (directiveId: string): Promise<boolean> => { + try { + await stopDirective(directiveId); + await fetchDirectives(); + return true; + } catch (err) { + console.error("Failed to stop directive:", err); + setError(err instanceof Error ? err.message : "Failed to stop directive"); + return false; + } + }, + [fetchDirectives] + ); + + return { + directives, + loading, + error, + refresh: fetchDirectives, + createNewDirective, + updateExistingDirective, + archiveExistingDirective, + getDirectiveById, + getGraph, + start, + pause, + resume, + stop, + }; +} + +/** Hook for subscribing to real-time directive events via SSE */ +export function useDirectiveEventSubscription( + directiveId: string | null, + onEvent?: (event: DirectiveEvent) => void +): { + events: DirectiveEvent[]; + isConnected: boolean; + error: string | null; +} { + const [events, setEvents] = useState<DirectiveEvent[]>([]); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState<string | null>(null); + const cleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + // Clean up any existing subscription + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + + if (!directiveId) { + setIsConnected(false); + setEvents([]); + return; + } + + // Subscribe to events + let mounted = true; + + const setupSubscription = async () => { + try { + const cleanup = await subscribeToDirectiveEvents( + directiveId, + (event) => { + if (mounted) { + setEvents((prev) => [...prev, event]); + onEvent?.(event); + } + }, + (err) => { + if (mounted) { + setError(err.message); + setIsConnected(false); + } + } + ); + + if (mounted) { + cleanupRef.current = cleanup; + setIsConnected(true); + setError(null); + } else { + // Component unmounted during setup, clean up immediately + cleanup(); + } + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err.message : "Failed to subscribe to events"); + setIsConnected(false); + } + } + }; + + setupSubscription(); + + return () => { + mounted = false; + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + }; + }, [directiveId, onEvent]); + + return { events, isConnected, error }; +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 2f4ee62..80a43eb 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3527,3 +3527,761 @@ export async function setChainRepositoryPrimary( } return res.json(); } + +// ============================================================================= +// Directive Types and API +// ============================================================================= + +/** Directive status */ +export type DirectiveStatus = + | "draft" + | "planning" + | "active" + | "paused" + | "completed" + | "archived" + | "failed"; + +/** Autonomy level */ +export type AutonomyLevel = "full_auto" | "guardrails" | "manual"; + +/** Confidence level (traffic light) */ +export type ConfidenceLevel = "green" | "yellow" | "red"; + +/** Step status */ +export type StepStatus = + | "pending" + | "ready" + | "running" + | "evaluating" + | "passed" + | "failed" + | "rework" + | "skipped" + | "blocked"; + +/** Evaluation type */ +export type EvaluationType = "programmatic" | "llm" | "composite" | "manual"; + +/** Directive summary for list view */ +export interface DirectiveSummary { + id: string; + title: string; + goal: string; + status: DirectiveStatus; + autonomyLevel: AutonomyLevel; + repositoryUrl: string | null; + currentChainId: string | null; + currentChainGeneration: number | null; + totalSteps: number; + completedSteps: number; + failedSteps: number; + currentConfidence: number | null; + totalCostUsd: number; + createdAt: string; + updatedAt: string; +} + +/** Full directive */ +export interface Directive { + id: string; + ownerId: string; + title: string; + goal: string; + status: DirectiveStatus; + autonomyLevel: AutonomyLevel; + repositoryUrl: string | null; + localPath: string | null; + requirements: unknown | null; + acceptanceCriteria: unknown | null; + constraints: unknown | null; + confidenceThresholdGreen: number; + confidenceThresholdYellow: number; + maxReworkCycles: number; + maxTotalCostUsd: number | null; + maxWallTimeMinutes: number | null; + maxChainRegenerations: number; + totalCostUsd: number; + currentChainId: string | null; + version: number; + createdAt: string; + updatedAt: string; + startedAt: string | null; + completedAt: string | null; +} + +/** Directive chain */ +export interface DirectiveChain { + id: string; + directiveId: string; + generation: number; + name: string; + description: string | null; + rationale: string | null; + planningModel: string | null; + status: string; + totalSteps: number; + completedSteps: number; + failedSteps: number; + currentConfidence: number | null; + startedAt: string | null; + completedAt: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +/** Chain step */ +export interface ChainStep { + id: string; + chainId: string; + name: string; + description: string | null; + stepType: string; + contractType: string; + initialPhase: string | null; + taskPlan: string | null; + phases: string[]; + dependsOn: string[]; + parallelGroup: string | null; + requirementIds: string[]; + acceptanceCriteriaIds: string[]; + verifierConfig: unknown; + status: StepStatus; + contractId: string | null; + supervisorTaskId: string | null; + confidenceScore: number | null; + confidenceLevel: ConfidenceLevel | null; + evaluationCount: number; + reworkCount: number; + lastEvaluationId: string | null; + editorX: number | null; + editorY: number | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** Directive with progress info */ +export interface DirectiveWithProgress extends Directive { + chain: DirectiveChain | null; + steps: ChainStep[]; + recentEvents: DirectiveEvent[]; + pendingApprovals: DirectiveApproval[]; +} + +/** Directive evaluation */ +export interface DirectiveEvaluation { + id: string; + directiveId: string; + chainId: string | null; + stepId: string | null; + evaluationType: EvaluationType; + passed: boolean; + overallScore: number; + confidenceLevel: ConfidenceLevel; + programmaticResults: unknown | null; + llmResults: unknown | null; + compositeBreakdown: unknown | null; + feedback: string | null; + reworkInstructions: string | null; + verifierIds: string[]; + evaluatedBy: string | null; + createdAt: string; +} + +/** Directive event */ +export interface DirectiveEvent { + id: string; + directiveId: string; + chainId: string | null; + stepId: string | null; + eventType: string; + severity: string; + eventData: unknown | null; + actorType: string; + actorId: string | null; + createdAt: string; +} + +/** Directive approval */ +export interface DirectiveApproval { + id: string; + directiveId: string; + chainId: string | null; + stepId: string | null; + approvalType: string; + description: string; + context: unknown | null; + urgency: string; + status: string; + requestedAt: string; + resolvedAt: string | null; + resolvedBy: string | null; + response: string | null; +} + +/** Directive verifier */ +export interface DirectiveVerifier { + id: string; + directiveId: string; + name: string; + verifierType: string; + command: string | null; + workingDirectory: string | null; + timeoutSeconds: number; + environment: unknown; + autoDetect: boolean; + detectFiles: string[]; + weight: number; + required: boolean; + enabled: boolean; + lastRunAt: string | null; + lastResult: unknown | null; + createdAt: string; + updatedAt: string; +} + +/** Directive graph node */ +export interface DirectiveGraphNode { + id: string; + name: string; + stepType: string; + status: StepStatus; + confidenceScore: number | null; + confidenceLevel: ConfidenceLevel | null; + contractId: string | null; + editorX: number | null; + editorY: number | null; +} + +/** Directive graph edge */ +export interface DirectiveGraphEdge { + source: string; + target: string; +} + +/** Directive graph response */ +export interface DirectiveGraphResponse { + chainId: string; + directiveId: string; + nodes: DirectiveGraphNode[]; + edges: DirectiveGraphEdge[]; +} + +/** Create directive request */ +export interface CreateDirectiveRequest { + goal: string; + repositoryUrl?: string; + localPath?: string; + autonomyLevel?: AutonomyLevel; + confidenceThresholdGreen?: number; + confidenceThresholdYellow?: number; + maxReworkCycles?: number; + maxTotalCostUsd?: number; + maxWallTimeMinutes?: number; +} + +/** Update directive request */ +export interface UpdateDirectiveRequest { + title?: string; + goal?: string; + requirements?: unknown; + acceptanceCriteria?: unknown; + constraints?: unknown; + autonomyLevel?: AutonomyLevel; + confidenceThresholdGreen?: number; + confidenceThresholdYellow?: number; + maxReworkCycles?: number; + maxTotalCostUsd?: number; + maxWallTimeMinutes?: number; + version?: number; +} + +/** Add step request */ +export interface AddStepRequest { + name: string; + description?: string; + stepType?: string; + contractType?: string; + initialPhase?: string; + taskPlan?: string; + phases?: string[]; + dependsOn?: string[]; + parallelGroup?: string; + requirementIds?: string[]; + acceptanceCriteriaIds?: string[]; + verifierConfig?: unknown; + editorX?: number; + editorY?: number; +} + +/** Update step request */ +export interface UpdateStepRequest { + name?: string; + description?: string; + initialPhase?: string; + taskPlan?: string; + phases?: string[]; + dependsOn?: string[]; + parallelGroup?: string; + requirementIds?: string[]; + acceptanceCriteriaIds?: string[]; + verifierConfig?: unknown; + editorX?: number; + editorY?: number; +} + +/** Create verifier request */ +export interface CreateVerifierRequest { + name: string; + verifierType: string; + command?: string; + workingDirectory?: string; + timeoutSeconds?: number; + weight?: number; + required?: boolean; + enabled?: boolean; +} + +/** Update verifier request */ +export interface UpdateVerifierRequest { + command?: string; + weight?: number; + required?: boolean; + enabled?: boolean; +} + +/** Approval action request */ +export interface ApprovalActionRequest { + response?: string; +} + +/** Start directive response */ +export interface StartDirectiveResponse { + directiveId: string; + chainId: string; + chainGeneration: number; + steps: ChainStep[]; + status: string; +} + +/** Directive list response */ +export interface DirectiveListResponse { + directives: DirectiveSummary[]; + total: number; +} + +// ============================================================================= +// Directive API Functions +// ============================================================================= + +/** List directives */ +export async function listDirectives( + status?: DirectiveStatus, + limit = 50, + offset = 0 +): Promise<DirectiveListResponse> { + const params = new URLSearchParams(); + if (status) params.set("status", status); + params.set("limit", String(limit)); + params.set("offset", String(offset)); + + const res = await authFetch(`${API_BASE}/api/v1/directives?${params}`); + if (!res.ok) { + throw new Error(`Failed to list directives: ${res.statusText}`); + } + return res.json(); +} + +/** Get directive by ID */ +export async function getDirective(directiveId: string): Promise<DirectiveWithProgress> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}`); + if (!res.ok) { + throw new Error(`Failed to get directive: ${res.statusText}`); + } + return res.json(); +} + +/** Create a new directive */ +export async function createDirective(req: CreateDirectiveRequest): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives`, { + method: "POST", + body: JSON.stringify(req), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create directive: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Update directive */ +export async function updateDirective( + directiveId: string, + req: UpdateDirectiveRequest +): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}`, { + method: "PUT", + body: JSON.stringify(req), + }); + if (!res.ok) { + throw new Error(`Failed to update directive: ${res.statusText}`); + } + return res.json(); +} + +/** Archive directive */ +export async function archiveDirective(directiveId: string): Promise<{ archived: boolean }> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to archive directive: ${res.statusText}`); + } + return res.json(); +} + +/** Start directive */ +export async function startDirective(directiveId: string): Promise<StartDirectiveResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/start`, { + method: "POST", + }); + if (!res.ok) { + throw new Error(`Failed to start directive: ${res.statusText}`); + } + return res.json(); +} + +/** Pause directive */ +export async function pauseDirective(directiveId: string): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/pause`, { + method: "POST", + }); + if (!res.ok) { + throw new Error(`Failed to pause directive: ${res.statusText}`); + } + return res.json(); +} + +/** Resume directive */ +export async function resumeDirective(directiveId: string): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/resume`, { + method: "POST", + }); + if (!res.ok) { + throw new Error(`Failed to resume directive: ${res.statusText}`); + } + return res.json(); +} + +/** Stop directive */ +export async function stopDirective(directiveId: string): Promise<Directive> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/stop`, { + method: "POST", + }); + if (!res.ok) { + throw new Error(`Failed to stop directive: ${res.statusText}`); + } + return res.json(); +} + +/** Get directive chain */ +export async function getDirectiveChain( + directiveId: string +): Promise<{ chain: DirectiveChain | null; steps: ChainStep[] }> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/chain`); + if (!res.ok) { + throw new Error(`Failed to get directive chain: ${res.statusText}`); + } + return res.json(); +} + +/** Get directive chain graph */ +export async function getDirectiveGraph(directiveId: string): Promise<DirectiveGraphResponse> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/chain/graph`); + if (!res.ok) { + throw new Error(`Failed to get directive graph: ${res.statusText}`); + } + return res.json(); +} + +/** Replan directive chain */ +export async function replanDirectiveChain(directiveId: string): Promise<DirectiveChain> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/chain/replan`, { + method: "POST", + }); + if (!res.ok) { + throw new Error(`Failed to replan directive chain: ${res.statusText}`); + } + return res.json(); +} + +/** Add step to directive chain */ +export async function addDirectiveStep( + directiveId: string, + req: AddStepRequest +): Promise<ChainStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/chain/steps`, { + method: "POST", + body: JSON.stringify(req), + }); + if (!res.ok) { + throw new Error(`Failed to add step: ${res.statusText}`); + } + return res.json(); +} + +/** Get step details */ +export async function getDirectiveStep( + directiveId: string, + stepId: string +): Promise<ChainStep> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}`); + if (!res.ok) { + throw new Error(`Failed to get step: ${res.statusText}`); + } + return res.json(); +} + +/** Update step */ +export async function updateDirectiveStep( + directiveId: string, + stepId: string, + req: UpdateStepRequest +): Promise<ChainStep> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/chain/steps/${stepId}`, + { + method: "PUT", + body: JSON.stringify(req), + } + ); + if (!res.ok) { + throw new Error(`Failed to update step: ${res.statusText}`); + } + return res.json(); +} + +/** Delete step */ +export async function deleteDirectiveStep( + directiveId: string, + stepId: string +): Promise<{ deleted: boolean }> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/chain/steps/${stepId}`, + { + method: "DELETE", + } + ); + if (!res.ok) { + throw new Error(`Failed to delete step: ${res.statusText}`); + } + return res.json(); +} + +/** Skip step */ +export async function skipDirectiveStep( + directiveId: string, + stepId: string +): Promise<ChainStep> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/steps/${stepId}/skip`, + { + method: "POST", + } + ); + if (!res.ok) { + throw new Error(`Failed to skip step: ${res.statusText}`); + } + return res.json(); +} + +/** List directive evaluations */ +export async function listDirectiveEvaluations( + directiveId: string, + limit = 50 +): Promise<DirectiveEvaluation[]> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/evaluations?limit=${limit}` + ); + if (!res.ok) { + throw new Error(`Failed to list evaluations: ${res.statusText}`); + } + return res.json(); +} + +/** List directive events */ +export async function listDirectiveEvents( + directiveId: string, + limit = 50 +): Promise<DirectiveEvent[]> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/events?limit=${limit}` + ); + if (!res.ok) { + throw new Error(`Failed to list events: ${res.statusText}`); + } + return res.json(); +} + +/** List directive verifiers */ +export async function listDirectiveVerifiers( + directiveId: string +): Promise<DirectiveVerifier[]> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/verifiers`); + if (!res.ok) { + throw new Error(`Failed to list verifiers: ${res.statusText}`); + } + return res.json(); +} + +/** Add verifier */ +export async function addDirectiveVerifier( + directiveId: string, + req: CreateVerifierRequest +): Promise<DirectiveVerifier> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/verifiers`, { + method: "POST", + body: JSON.stringify(req), + }); + if (!res.ok) { + throw new Error(`Failed to add verifier: ${res.statusText}`); + } + return res.json(); +} + +/** Update verifier */ +export async function updateDirectiveVerifier( + directiveId: string, + verifierId: string, + req: UpdateVerifierRequest +): Promise<DirectiveVerifier> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/verifiers/${verifierId}`, + { + method: "PUT", + body: JSON.stringify(req), + } + ); + if (!res.ok) { + throw new Error(`Failed to update verifier: ${res.statusText}`); + } + return res.json(); +} + +/** Auto-detect verifiers */ +export async function autoDetectVerifiers( + directiveId: string +): Promise<DirectiveVerifier[]> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/verifiers/auto-detect`, + { + method: "POST", + } + ); + if (!res.ok) { + throw new Error(`Failed to auto-detect verifiers: ${res.statusText}`); + } + return res.json(); +} + +/** List pending approvals */ +export async function listDirectiveApprovals( + directiveId: string +): Promise<DirectiveApproval[]> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/approvals`); + if (!res.ok) { + throw new Error(`Failed to list approvals: ${res.statusText}`); + } + return res.json(); +} + +/** Approve request */ +export async function approveDirectiveRequest( + directiveId: string, + approvalId: string, + req?: ApprovalActionRequest +): Promise<DirectiveApproval> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/approvals/${approvalId}/approve`, + { + method: "POST", + body: JSON.stringify(req || {}), + } + ); + if (!res.ok) { + throw new Error(`Failed to approve request: ${res.statusText}`); + } + return res.json(); +} + +/** Deny request */ +export async function denyDirectiveRequest( + directiveId: string, + approvalId: string, + req?: ApprovalActionRequest +): Promise<DirectiveApproval> { + const res = await authFetch( + `${API_BASE}/api/v1/directives/${directiveId}/approvals/${approvalId}/deny`, + { + method: "POST", + body: JSON.stringify(req || {}), + } + ); + if (!res.ok) { + throw new Error(`Failed to deny request: ${res.statusText}`); + } + return res.json(); +} + +/** Subscribe to directive events via SSE */ +export async function subscribeToDirectiveEvents( + directiveId: string, + onEvent: (event: DirectiveEvent) => void, + onError?: (error: Error) => void +): Promise<() => void> { + // Get auth token for the request + let authToken: string | null = null; + if (supabase) { + const { data: { session } } = await supabase.auth.getSession(); + if (session?.access_token) { + authToken = session.access_token; + } + } + + // Build URL with auth token as query param (since EventSource doesn't support headers) + const url = new URL(`${API_BASE}/api/v1/directives/${directiveId}/events/stream`); + if (authToken) { + url.searchParams.set("token", authToken); + } else { + const apiKey = getStoredApiKey(); + if (apiKey) { + url.searchParams.set("api_key", apiKey); + } + } + + // Create EventSource connection + const eventSource = new EventSource(url.toString()); + + eventSource.onmessage = (e) => { + try { + const event = JSON.parse(e.data) as DirectiveEvent; + onEvent(event); + } catch (err) { + console.error("Failed to parse SSE event:", err); + } + }; + + eventSource.onerror = (_e) => { + if (onError) { + onError(new Error("SSE connection error")); + } + }; + + // Return cleanup function + return () => { + eventSource.close(); + }; +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index a7ba1a3..5a1b98e 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -13,6 +13,7 @@ import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; import ChainsPage from "./routes/chains"; +import DirectivesPage from "./routes/directives"; import WorkflowPage from "./routes/workflow"; import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; @@ -89,6 +90,22 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/directives" + element={ + <ProtectedRoute> + <DirectivesPage /> + </ProtectedRoute> + } + /> + <Route + path="/directives/:id" + element={ + <ProtectedRoute> + <DirectivesPage /> + </ProtectedRoute> + } + /> + <Route path="/contracts/:id/files/:fileId" element={ <ProtectedRoute> 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 ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex items-center justify-center"> + <p className="text-[#7788aa] font-mono text-sm">Loading...</p> + </main> + </div> + ); + } + + // Don't render if not authenticated (will redirect) + if (isAuthConfigured && !isAuthenticated) { + return null; + } + + return <DirectivesPageContent />; +} + +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<DirectiveWithProgress | null>(null); + const [directiveGraph, setDirectiveGraph] = useState<DirectiveGraphResponse | null>(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 ( + <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <Masthead showNav /> + <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> + {error && ( + <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> + {error} + </div> + )} + + {/* Create directive modal */} + {isCreating && ( + <CreateDirectiveModal + onSubmit={handleCreateSubmit} + onCancel={handleCreateCancel} + /> + )} + + <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> + {/* Directive list */} + <DirectiveList + directives={directives} + loading={loading} + onSelect={handleSelect} + onCreate={handleCreate} + selectedId={id} + onArchive={handleArchive} + /> + + {/* Directive detail or empty state */} + {directiveDetail ? ( + <DirectiveDetail + directive={directiveDetail} + graph={directiveGraph} + loading={detailLoading} + onBack={handleBack} + onRefresh={handleRefresh} + onStart={handleStart} + onPause={handlePause} + onResume={handleResume} + onStop={handleStop} + /> + ) : ( + <div className="panel h-full flex items-center justify-center"> + <div className="text-center"> + <p className="font-mono text-sm text-[#555] mb-4"> + Select a directive or create a new one + </p> + <button + onClick={handleCreate} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New Directive + </button> + </div> + </div> + )} + </div> + </main> + </div> + ); +} + +// ============================================================================= +// 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 ( + <div className="panel h-full flex flex-col overflow-hidden"> + <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase">Directives</h2> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" + > + + New + </button> + </div> + + {/* Filters */} + <div className="flex gap-1 p-2 border-b border-[rgba(117,170,252,0.1)]"> + {(["all", "active", "completed", "failed"] as const).map((f) => ( + <button + key={f} + onClick={() => setFilter(f)} + className={`px-2 py-1 font-mono text-[10px] uppercase ${ + filter === f + ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" + : "text-[#556677] hover:text-[#9bc3ff]" + }`} + > + {f} + </button> + ))} + </div> + + {/* List */} + <div className="flex-1 overflow-y-auto"> + {loading ? ( + <div className="p-4 text-center"> + <p className="font-mono text-xs text-[#556677]">Loading...</p> + </div> + ) : filteredDirectives.length === 0 ? ( + <div className="p-4 text-center"> + <p className="font-mono text-xs text-[#556677]">No directives found</p> + </div> + ) : ( + filteredDirectives.map((d) => ( + <DirectiveListItem + key={d.id} + directive={d} + selected={d.id === selectedId} + onClick={() => onSelect(d.id)} + onArchive={() => onArchive(d)} + /> + )) + )} + </div> + </div> + ); +} + +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 ( + <div + onClick={onClick} + className={`p-3 cursor-pointer border-b border-[rgba(117,170,252,0.1)] hover:bg-[rgba(117,170,252,0.05)] ${ + selected ? "bg-[rgba(117,170,252,0.1)]" : "" + }`} + > + <div className="flex items-start justify-between gap-2"> + <div className="flex-1 min-w-0"> + <div className="font-mono text-sm text-[#dbe7ff] truncate"> + {directive.title || directive.goal.slice(0, 50)} + </div> + <div className="flex items-center gap-2 mt-1"> + <span className={`font-mono text-[10px] uppercase ${statusColor}`}> + {directive.status} + </span> + <span className="font-mono text-[10px] text-[#556677]"> + {directive.completedSteps}/{directive.totalSteps} steps + </span> + </div> + </div> + <div className="flex flex-col items-end gap-1"> + {directive.currentConfidence !== null && ( + <div className={`w-2 h-2 rounded-full ${confidenceColor}`} title={`Confidence: ${Math.round(directive.currentConfidence * 100)}%`} /> + )} + <button + onClick={(e) => { + e.stopPropagation(); + onArchive(); + }} + className="font-mono text-[10px] text-[#556677] hover:text-red-400" + > + Archive + </button> + </div> + </div> + + {/* Progress bar */} + {directive.totalSteps > 0 && ( + <div className="mt-2 h-1 bg-[rgba(117,170,252,0.1)] overflow-hidden"> + <div + className="h-full bg-[#75aafc] transition-all duration-300" + style={{ width: `${progress}%` }} + /> + </div> + )} + </div> + ); +} + +// ============================================================================= +// 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 ( + <div className="panel h-full flex items-center justify-center"> + <p className="font-mono text-xs text-[#556677]">Loading...</p> + </div> + ); + } + + 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 ( + <div className="panel h-full flex flex-col overflow-hidden"> + {/* Header */} + <div className="p-3 border-b border-[rgba(117,170,252,0.15)]"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button + onClick={onBack} + className="font-mono text-xs text-[#556677] hover:text-[#9bc3ff]" + > + ← Back + </button> + <h2 className="font-mono text-sm text-[#dbe7ff]"> + {directive.title || directive.goal.slice(0, 50)} + </h2> + <span className={`px-2 py-0.5 font-mono text-[10px] uppercase border ${statusColor}`}> + {directive.status} + </span> + </div> + <div className="flex items-center gap-2"> + {directive.status === "draft" && ( + <button + onClick={onStart} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" + > + Start + </button> + )} + {directive.status === "active" && ( + <button + onClick={onPause} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-yellow-700 border border-yellow-600 hover:bg-yellow-600 uppercase" + > + Pause + </button> + )} + {directive.status === "paused" && ( + <button + onClick={onResume} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" + > + Resume + </button> + )} + {["active", "paused"].includes(directive.status) && ( + <button + onClick={onStop} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-red-700 border border-red-600 hover:bg-red-600 uppercase" + > + Stop + </button> + )} + <button + onClick={onRefresh} + className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff]" + > + Refresh + </button> + </div> + </div> + </div> + + {/* Tabs */} + <div className="flex gap-1 p-2 border-b border-[rgba(117,170,252,0.1)]"> + {(["overview", "chain", "events", "evaluations", "approvals", "verifiers"] as const).map((tab) => ( + <button + key={tab} + onClick={() => setActiveTab(tab)} + className={`px-3 py-1.5 font-mono text-[10px] uppercase ${ + activeTab === tab + ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" + : "text-[#556677] hover:text-[#9bc3ff]" + }`} + > + {tab} + {tab === "approvals" && directive.pendingApprovals.length > 0 && ( + <span className="ml-1 px-1 bg-yellow-500 text-black rounded text-[8px]"> + {directive.pendingApprovals.length} + </span> + )} + </button> + ))} + </div> + + {/* Tab Content */} + <div className="flex-1 overflow-y-auto p-4"> + {activeTab === "overview" && ( + <OverviewTab directive={directive} /> + )} + {activeTab === "chain" && ( + <ChainTab directive={directive} graph={graph} /> + )} + {activeTab === "events" && ( + <EventsTab directive={directive} /> + )} + {activeTab === "evaluations" && ( + <EvaluationsTab directive={directive} /> + )} + {activeTab === "approvals" && ( + <ApprovalsTab directive={directive} onRefresh={onRefresh} /> + )} + {activeTab === "verifiers" && ( + <VerifiersTab directive={directive} /> + )} + </div> + </div> + ); +} + +// ============================================================================= +// Tab Components +// ============================================================================= + +function OverviewTab({ directive }: { directive: DirectiveWithProgress }) { + return ( + <div className="space-y-6"> + {/* Goal */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Goal</h3> + <p className="font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap"> + {directive.goal} + </p> + </div> + + {/* Progress */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Progress</h3> + <div className="grid grid-cols-3 gap-4"> + <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div className="font-mono text-2xl text-[#dbe7ff]"> + {directive.chain?.completedSteps || 0} + </div> + <div className="font-mono text-[10px] text-[#556677] uppercase">Completed Steps</div> + </div> + <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div className="font-mono text-2xl text-[#dbe7ff]"> + {directive.chain?.totalSteps || 0} + </div> + <div className="font-mono text-[10px] text-[#556677] uppercase">Total Steps</div> + </div> + <div className="p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div className="font-mono text-2xl text-[#dbe7ff]"> + {directive.chain?.currentConfidence != null + ? `${Math.round((directive.chain?.currentConfidence ?? 0) * 100)}%` + : "-"} + </div> + <div className="font-mono text-[10px] text-[#556677] uppercase">Confidence</div> + </div> + </div> + </div> + + {/* Configuration */} + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Configuration</h3> + <div className="grid grid-cols-2 gap-2 text-sm"> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Autonomy Level</span> + <span className="font-mono text-[#dbe7ff]">{directive.autonomyLevel}</span> + </div> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Max Rework Cycles</span> + <span className="font-mono text-[#dbe7ff]">{directive.maxReworkCycles}</span> + </div> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Green Threshold</span> + <span className="font-mono text-[#dbe7ff]">{directive.confidenceThresholdGreen}</span> + </div> + <div className="flex justify-between"> + <span className="font-mono text-[#556677]">Yellow Threshold</span> + <span className="font-mono text-[#dbe7ff]">{directive.confidenceThresholdYellow}</span> + </div> + </div> + </div> + + {/* Repository */} + {directive.repositoryUrl && ( + <div> + <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2">Repository</h3> + <p className="font-mono text-sm text-[#9bc3ff]">{directive.repositoryUrl}</p> + </div> + )} + </div> + ); +} + +// Step status colors for both list and DAG views +const stepStatusStyles: Record<StepStatus, { border: string; bg: string; text: string }> = { + 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<ConfidenceLevel, string> = { + 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 ( + <div + className={`rounded-lg border-2 bg-[#0a1628] overflow-hidden ${ + isRunning ? "animate-pulse" : "" + }`} + style={{ + width: NODE_WIDTH, + height: NODE_HEIGHT, + borderColor: styles.border, + borderStyle: data.status === "pending" ? "dashed" : "solid", + }} + > + <Handle + type="target" + position={Position.Top} + className="!bg-[#75aafc] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + + {/* Status indicator bar */} + <div className="h-1.5" style={{ backgroundColor: styles.bg }} /> + + {/* Content */} + <div className="p-2"> + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">{data.name}</span> + {data.confidenceScore !== null && data.confidenceLevel && ( + <div + className="w-2 h-2 rounded-full flex-shrink-0 ml-1" + style={{ backgroundColor: confidenceColors[data.confidenceLevel] }} + title={`Confidence: ${Math.round(data.confidenceScore * 100)}%`} + /> + )} + </div> + <div className="flex items-center justify-between"> + <span + className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded" + style={{ + color: styles.text, + backgroundColor: `${styles.bg}20`, + }} + > + {data.status} + </span> + <span className="font-mono text-[10px] text-[#8b949e]">{data.stepType}</span> + </div> + </div> + + <Handle + type="source" + position={Position.Bottom} + className="!bg-[#f59e0b] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + </div> + ); +} + +// 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 ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]"> + No chain generated yet. Start the directive to generate a chain. + </p> + </div> + ); + } + + return ( + <div className="space-y-4"> + {/* Chain info header */} + <div className="flex items-center justify-between p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.15)]"> + <div> + <div className="font-mono text-sm text-[#dbe7ff]">{directive.chain.name}</div> + <div className="font-mono text-[10px] text-[#556677]">Generation {directive.chain.generation}</div> + </div> + <div className="flex items-center gap-4"> + <div className="font-mono text-xs text-[#556677]"> + {directive.chain.completedSteps}/{directive.chain.totalSteps} steps + </div> + {/* View toggle */} + <div className="flex border border-[rgba(117,170,252,0.2)]"> + <button + onClick={() => setViewMode("dag")} + className={`px-2 py-1 font-mono text-[10px] uppercase ${ + viewMode === "dag" + ? "bg-[rgba(117,170,252,0.2)] text-[#75aafc]" + : "text-[#556677] hover:text-[#75aafc]" + }`} + > + DAG + </button> + <button + onClick={() => setViewMode("list")} + className={`px-2 py-1 font-mono text-[10px] uppercase ${ + viewMode === "list" + ? "bg-[rgba(117,170,252,0.2)] text-[#75aafc]" + : "text-[#556677] hover:text-[#75aafc]" + }`} + > + List + </button> + </div> + </div> + </div> + + {viewMode === "dag" ? ( + /* DAG visualization */ + <div className="h-[400px] border border-[rgba(117,170,252,0.1)] rounded bg-[#050d18]"> + {directive.steps.length === 0 ? ( + <div className="flex items-center justify-center h-full"> + <p className="font-mono text-sm text-[#556677]">No steps in chain</p> + </div> + ) : ( + <ReactFlow + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + fitView + fitViewOptions={{ padding: 0.2 }} + minZoom={0.5} + maxZoom={1.5} + defaultEdgeOptions={{ + type: "smoothstep", + style: { stroke: "#556677", strokeWidth: 2 }, + }} + > + <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#1a2a3a" /> + <Controls className="!bg-[#0a1628] !border-[rgba(117,170,252,0.2)]" /> + </ReactFlow> + )} + </div> + ) : ( + /* List view */ + <div className="space-y-2"> + <h3 className="font-mono text-xs text-[#75aafc] uppercase">Steps</h3> + {directive.steps.length === 0 ? ( + <p className="font-mono text-sm text-[#556677]">No steps in chain</p> + ) : ( + directive.steps.map((step) => { + const styles = stepStatusStyles[step.status] || stepStatusStyles.pending; + + return ( + <div + key={step.id} + className="p-3 bg-[rgba(117,170,252,0.02)] border border-[rgba(117,170,252,0.1)]" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]">{step.name}</span> + <span + className="px-1.5 py-0.5 font-mono text-[10px] uppercase border" + style={{ color: styles.text, borderColor: `${styles.border}50` }} + > + {step.status} + </span> + {step.confidenceScore !== null && ( + <span className="font-mono text-[10px] text-[#556677]"> + ({Math.round(step.confidenceScore * 100)}%) + </span> + )} + </div> + <div className="font-mono text-[10px] text-[#556677]">{step.stepType}</div> + </div> + {step.description && ( + <p className="font-mono text-xs text-[#556677] mt-1">{step.description}</p> + )} + {step.dependsOn.length > 0 && ( + <div className="font-mono text-[10px] text-[#556677] mt-1"> + Depends on: {step.dependsOn.join(", ")} + </div> + )} + </div> + ); + }) + )} + </div> + )} + </div> + ); +} + +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 ( + <div className="space-y-4"> + {/* Connection status */} + <div className="flex items-center justify-between text-[10px] font-mono"> + <div className="flex items-center gap-2"> + <span className={isConnected ? "text-green-400" : "text-[#556677]"}> + {isConnected ? "● Live" : "○ Connecting..."} + </span> + {sseError && <span className="text-red-400">{sseError}</span>} + </div> + <span className="text-[#556677]">{allEvents.length} events</span> + </div> + + {/* Event list */} + {allEvents.length === 0 ? ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]">No events yet</p> + </div> + ) : ( + <div className="space-y-2"> + {allEvents.map((event) => { + const severityColors: Record<string, string> = { + info: "text-[#75aafc]", + warning: "text-yellow-400", + error: "text-red-400", + critical: "text-red-600", + }; + const severityColor = severityColors[event.severity] || "text-[#556677]"; + + return ( + <div + key={event.id} + className="p-3 bg-[rgba(117,170,252,0.02)] border border-[rgba(117,170,252,0.1)]" + > + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <span className={`font-mono text-xs ${severityColor}`}>{event.eventType}</span> + <span className="font-mono text-[10px] text-[#556677]">{event.actorType}</span> + </div> + <span className="font-mono text-[10px] text-[#556677]"> + {new Date(event.createdAt).toLocaleString()} + </span> + </div> + {event.eventData != null && ( + <pre className="font-mono text-[10px] text-[#556677] mt-1 overflow-x-auto"> + {JSON.stringify(event.eventData, null, 2)} + </pre> + )} + </div> + ); + })} + </div> + )} + </div> + ); +} + +function EvaluationsTab({ directive: _directive }: { directive: DirectiveWithProgress }) { + // TODO: Fetch evaluations separately + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]"> + Evaluations will be shown here after steps are evaluated + </p> + </div> + ); +} + +function ApprovalsTab({ directive, onRefresh }: { directive: DirectiveWithProgress; onRefresh: () => void }) { + if (directive.pendingApprovals.length === 0) { + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]">No pending approvals</p> + </div> + ); + } + + 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 ( + <div className="space-y-3"> + {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 ( + <div + key={approval.id} + className="p-4 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]" + > + <div className="flex items-start justify-between"> + <div> + <div className="flex items-center gap-2"> + <span className="font-mono text-sm text-[#dbe7ff]">{approval.approvalType}</span> + <span className={`font-mono text-[10px] uppercase ${urgencyColor}`}> + {approval.urgency} + </span> + </div> + <p className="font-mono text-xs text-[#9bc3ff] mt-1">{approval.description}</p> + </div> + <div className="flex gap-2"> + <button + onClick={() => handleApprove(approval.id)} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-green-700 border border-green-600 hover:bg-green-600 uppercase" + > + Approve + </button> + <button + onClick={() => handleDeny(approval.id)} + className="px-3 py-1 font-mono text-[10px] text-[#dbe7ff] bg-red-700 border border-red-600 hover:bg-red-600 uppercase" + > + Deny + </button> + </div> + </div> + </div> + ); + })} + </div> + ); +} + +function VerifiersTab({ directive: _directive }: { directive: DirectiveWithProgress }) { + // TODO: Fetch verifiers separately + return ( + <div className="text-center py-8"> + <p className="font-mono text-sm text-[#556677]"> + Verifiers will be shown here. Use auto-detect to find available verifiers. + </p> + </div> + ); +} + +// ============================================================================= +// 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<AutonomyLevel>("guardrails"); + const [suggestions, setSuggestions] = useState<RepositoryHistoryEntry[]>([]); + 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 ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[90vh] overflow-y-auto"> + <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> + Create Directive + </h3> + + <div className="space-y-4"> + {/* Goal */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Goal * + </label> + <textarea + value={goal} + onChange={(e) => setGoal(e.target.value)} + placeholder="Describe what you want to accomplish..." + rows={3} + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" + autoFocus + /> + </div> + + {/* Repository URL */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Repository URL (optional) + </label> + <div className="relative"> + <input + type="text" + value={repositoryUrl} + onChange={(e) => setRepositoryUrl(e.target.value)} + onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} + placeholder="https://github.com/owner/repo" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + /> + {showSuggestions && suggestions.length > 0 && ( + <div className="absolute top-full left-0 right-0 mt-1 border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto z-10"> + {suggestions.map((s) => ( + <button + key={s.id} + type="button" + onClick={() => { + setRepositoryUrl(s.repositoryUrl || ""); + setShowSuggestions(false); + }} + className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + <div className="text-[#9bc3ff] truncate">{s.name}</div> + <div className="text-[10px] text-[#556677] truncate">{s.repositoryUrl}</div> + </button> + ))} + </div> + )} + </div> + </div> + + {/* Autonomy Level */} + <div> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-2"> + Autonomy Level + </label> + <div className="flex gap-2"> + {(["full_auto", "guardrails", "manual"] as const).map((level) => ( + <button + key={level} + type="button" + onClick={() => setAutonomyLevel(level)} + className={`flex-1 px-3 py-2 font-mono text-xs uppercase ${ + autonomyLevel === level + ? "text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3]" + : "text-[#556677] border border-[rgba(117,170,252,0.2)] hover:border-[#3f6fb3]" + }`} + > + {level.replace("_", " ")} + </button> + ))} + </div> + <p className="font-mono text-[10px] text-[#556677] mt-1"> + {autonomyLevel === "full_auto" && "Automatic progression without approval gates"} + {autonomyLevel === "guardrails" && "Request approval for yellow/red confidence scores"} + {autonomyLevel === "manual" && "Request approval for all step completions"} + </p> + </div> + + <p className="font-mono text-xs text-[#8b949e]"> + A directive is a top-level goal that generates a chain of steps. Each step spawns + contracts that are verified before progression. + </p> + + {/* Actions */} + <div className="flex gap-2 justify-end pt-2"> + <button + onClick={onCancel} + className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + Cancel + </button> + <button + onClick={handleSubmit} + disabled={!goal.trim()} + className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + Create + </button> + </div> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo index 425babe..966ee38 100644 --- a/makima/frontend/tsconfig.tsbuildinfo +++ b/makima/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/chains/chaineditor.tsx","./src/components/chains/chainlist.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usechains.ts","./src/hooks/usecontracts.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/chains.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/japanesehovertext.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/phaseconfirmationnotification.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/supervisorquestionnotification.tsx","./src/components/chains/chaineditor.tsx","./src/components/chains/chainlist.tsx","./src/components/charts/chartrenderer.tsx","./src/components/contracts/commandmodepanel.tsx","./src/components/contracts/contractcliinput.tsx","./src/components/contracts/contractcontextmenu.tsx","./src/components/contracts/contractdetail.tsx","./src/components/contracts/contractlist.tsx","./src/components/contracts/phasebadge.tsx","./src/components/contracts/phaseconfirmationmodal.tsx","./src/components/contracts/phasedeliverablespanel.tsx","./src/components/contracts/phasehint.tsx","./src/components/contracts/phaseprogressbar.tsx","./src/components/contracts/quickactionbuttons.tsx","./src/components/contracts/repositorypanel.tsx","./src/components/contracts/taskderivationpreview.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/reposyncindicator.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/history/checkpointcard.tsx","./src/components/history/checkpointlist.tsx","./src/components/history/conversationmessage.tsx","./src/components/history/conversationview.tsx","./src/components/history/historyfilters.tsx","./src/components/history/resumecontrols.tsx","./src/components/history/timelineeventcard.tsx","./src/components/history/timelinelist.tsx","./src/components/history/index.ts","./src/components/listen/contractpickermodal.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/discusscontractmodal.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptanalysispanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/branchtaskmodal.tsx","./src/components/mesh/contractcompletequestion.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/gitactionspanel.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/patcheslistpanel.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/tasktree.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/components/mesh/worktreefilespanel.tsx","./src/components/workflow/phasecolumn.tsx","./src/components/workflow/workflowboard.tsx","./src/components/workflow/workflowcontractcard.tsx","./src/contexts/authcontext.tsx","./src/contexts/supervisorquestionscontext.tsx","./src/hooks/usechains.ts","./src/hooks/usecontracts.ts","./src/hooks/usedirectives.ts","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usespeakwebsocket.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/listenapi.ts","./src/lib/markdown.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/chains.tsx","./src/routes/contract-file.tsx","./src/routes/contracts.tsx","./src/routes/directives.tsx","./src/routes/files.tsx","./src/routes/history.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/routes/speak.tsx","./src/routes/workflow.tsx","./src/types/messages.ts"],"version":"5.9.3"}
\ No newline at end of file diff --git a/makima/migrations/20260206000000_create_directive_system.sql b/makima/migrations/20260206000000_create_directive_system.sql new file mode 100644 index 0000000..ed780ef --- /dev/null +++ b/makima/migrations/20260206000000_create_directive_system.sql @@ -0,0 +1,320 @@ +-- ============================================================================ +-- Migration: create_directive_system.sql +-- Replaces: chains, chain_contracts, chain_contract_definitions, +-- chain_directives, contract_evaluations, chain_events, +-- chain_repositories +-- ============================================================================ + +-- Drop old chain system tables (cascade to remove FKs) +DROP TABLE IF EXISTS contract_evaluations CASCADE; +DROP TABLE IF EXISTS chain_events CASCADE; +DROP TABLE IF EXISTS chain_contract_definitions CASCADE; +DROP TABLE IF EXISTS chain_contracts CASCADE; +DROP TABLE IF EXISTS chain_directives CASCADE; +DROP TABLE IF EXISTS chain_repositories CASCADE; +DROP TABLE IF EXISTS chains CASCADE; + +-- Remove old chain-related columns from contracts +ALTER TABLE contracts DROP COLUMN IF EXISTS chain_id; +ALTER TABLE contracts DROP COLUMN IF EXISTS spawned_chain_id; +ALTER TABLE contracts DROP COLUMN IF EXISTS is_chain_directive; + +-- ============================================================================ +-- 1. DIRECTIVES -- the top-level entity +-- ============================================================================ +CREATE TABLE directives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + + -- Goal specification + title VARCHAR(500) NOT NULL, + goal TEXT NOT NULL, + + -- Structured specification (JSONB arrays) + requirements JSONB NOT NULL DEFAULT '[]', + acceptance_criteria JSONB NOT NULL DEFAULT '[]', + constraints JSONB NOT NULL DEFAULT '[]', + external_dependencies JSONB NOT NULL DEFAULT '[]', + + -- State + status VARCHAR(32) NOT NULL DEFAULT 'draft', + + -- Autonomy configuration + autonomy_level VARCHAR(32) NOT NULL DEFAULT 'guardrails', + confidence_threshold_green FLOAT NOT NULL DEFAULT 0.85, + confidence_threshold_yellow FLOAT NOT NULL DEFAULT 0.60, + + -- Circuit breaker limits + max_total_cost_usd FLOAT, + max_wall_time_minutes INTEGER, + max_rework_cycles INTEGER DEFAULT 3, + max_chain_regenerations INTEGER DEFAULT 2, + + -- Repository configuration (inherited by all steps) + repository_url VARCHAR(512), + local_path VARCHAR(512), + base_branch VARCHAR(255), + + -- Orchestrator contract reference + orchestrator_contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL, + + -- Tracking + current_chain_id UUID, -- FK added after directive_chains table + chain_generation_count INTEGER NOT NULL DEFAULT 0, + total_cost_usd FLOAT NOT NULL DEFAULT 0.0, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_directives_owner_id ON directives(owner_id); +CREATE INDEX idx_directives_status ON directives(status); + +-- Add directive reference to contracts +ALTER TABLE contracts ADD COLUMN directive_id UUID REFERENCES directives(id) ON DELETE SET NULL; +ALTER TABLE contracts ADD COLUMN is_directive_orchestrator BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE contracts ADD COLUMN spawned_directive_id UUID REFERENCES directives(id) ON DELETE SET NULL; + +-- ============================================================================ +-- 2. DIRECTIVE CHAINS -- generated execution plans +-- ============================================================================ +CREATE TABLE directive_chains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + generation INTEGER NOT NULL DEFAULT 1, + + -- Plan metadata + name VARCHAR(255) NOT NULL, + description TEXT, + rationale TEXT, + planning_model VARCHAR(100), + + -- State + status VARCHAR(32) NOT NULL DEFAULT 'pending', + + -- Execution tracking + total_steps INTEGER NOT NULL DEFAULT 0, + completed_steps INTEGER NOT NULL DEFAULT 0, + failed_steps INTEGER NOT NULL DEFAULT 0, + current_confidence FLOAT, + + -- Timestamps + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_directive_chains_directive ON directive_chains(directive_id); +CREATE INDEX idx_directive_chains_status ON directive_chains(status); + +-- Add FK from directives to chains +ALTER TABLE directives + ADD CONSTRAINT fk_directives_current_chain + FOREIGN KEY (current_chain_id) REFERENCES directive_chains(id) + ON DELETE SET NULL; + +-- ============================================================================ +-- 3. CHAIN STEPS -- nodes in the DAG +-- ============================================================================ +CREATE TABLE chain_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chain_id UUID NOT NULL REFERENCES directive_chains(id) ON DELETE CASCADE, + + -- Step definition + name VARCHAR(255) NOT NULL, + description TEXT, + step_type VARCHAR(32) NOT NULL DEFAULT 'execute', + + -- Contract template + contract_type VARCHAR(32) NOT NULL DEFAULT 'simple', + initial_phase VARCHAR(32) DEFAULT 'plan', + task_plan TEXT, + phases TEXT[] DEFAULT '{}', + + -- DAG edges + depends_on UUID[] DEFAULT '{}', + parallel_group VARCHAR(100), + + -- Requirement traceability + requirement_ids TEXT[] DEFAULT '{}', + acceptance_criteria_ids TEXT[] DEFAULT '{}', + + -- Verification configuration + verifier_config JSONB DEFAULT '{}', + + -- State + status VARCHAR(32) NOT NULL DEFAULT 'pending', + + -- Instantiated references + contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL, + supervisor_task_id UUID, + + -- Evaluation tracking + confidence_score FLOAT, + confidence_level VARCHAR(10), + evaluation_count INTEGER NOT NULL DEFAULT 0, + rework_count INTEGER NOT NULL DEFAULT 0, + last_evaluation_id UUID, + + -- Editor layout + editor_x FLOAT DEFAULT 0, + editor_y FLOAT DEFAULT 0, + order_index INTEGER NOT NULL DEFAULT 0, + + -- Timestamps + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_chain_steps_chain ON chain_steps(chain_id); +CREATE INDEX idx_chain_steps_status ON chain_steps(status); +CREATE INDEX idx_chain_steps_contract ON chain_steps(contract_id) WHERE contract_id IS NOT NULL; + +-- ============================================================================ +-- 4. EVALUATIONS -- programmatic + LLM composite +-- ============================================================================ +CREATE TABLE directive_evaluations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + chain_id UUID REFERENCES directive_chains(id) ON DELETE SET NULL, + step_id UUID REFERENCES chain_steps(id) ON DELETE SET NULL, + contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL, + + -- Evaluation metadata + evaluation_type VARCHAR(32) NOT NULL, + evaluation_number INTEGER NOT NULL DEFAULT 1, + evaluator VARCHAR(100), + + -- Results + passed BOOLEAN NOT NULL, + overall_score FLOAT, + confidence_level VARCHAR(10), + + -- Programmatic results + programmatic_results JSONB DEFAULT '[]', + + -- LLM evaluation results + llm_results JSONB DEFAULT '{}', + + -- Composite results + criteria_results JSONB NOT NULL DEFAULT '[]', + summary_feedback TEXT NOT NULL DEFAULT '', + rework_instructions TEXT, + + -- Snapshots + directive_snapshot JSONB, + deliverables_snapshot JSONB, + + -- Timing + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_evaluations_directive ON directive_evaluations(directive_id); +CREATE INDEX idx_evaluations_step ON directive_evaluations(step_id); +CREATE INDEX idx_evaluations_chain ON directive_evaluations(chain_id); + +-- Add FK from chain_steps to evaluations +ALTER TABLE chain_steps + ADD CONSTRAINT fk_steps_last_evaluation + FOREIGN KEY (last_evaluation_id) REFERENCES directive_evaluations(id) + ON DELETE SET NULL; + +-- ============================================================================ +-- 5. EVENTS -- comprehensive audit stream +-- ============================================================================ +CREATE TABLE directive_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + chain_id UUID REFERENCES directive_chains(id) ON DELETE SET NULL, + step_id UUID REFERENCES chain_steps(id) ON DELETE SET NULL, + + -- Event classification + event_type VARCHAR(64) NOT NULL, + severity VARCHAR(16) NOT NULL DEFAULT 'info', + + -- Payload + event_data JSONB, + + -- Actor + actor_type VARCHAR(32) NOT NULL DEFAULT 'system', + actor_id UUID, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_events_directive ON directive_events(directive_id); +CREATE INDEX idx_events_chain ON directive_events(chain_id); +CREATE INDEX idx_events_step ON directive_events(step_id); +CREATE INDEX idx_events_type ON directive_events(event_type); +CREATE INDEX idx_events_created ON directive_events(created_at); + +-- ============================================================================ +-- 6. VERIFIERS -- pluggable verification config +-- ============================================================================ +CREATE TABLE directive_verifiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + + -- Definition + name VARCHAR(100) NOT NULL, + verifier_type VARCHAR(32) NOT NULL, + + -- Configuration + command VARCHAR(1000), + working_directory VARCHAR(500), + timeout_seconds INTEGER DEFAULT 300, + environment JSONB DEFAULT '{}', + + -- Detection + auto_detect BOOLEAN NOT NULL DEFAULT true, + detect_files TEXT[] DEFAULT '{}', + + -- Scoring + weight FLOAT NOT NULL DEFAULT 1.0, + required BOOLEAN NOT NULL DEFAULT false, + + -- State + enabled BOOLEAN NOT NULL DEFAULT true, + last_run_at TIMESTAMPTZ, + last_result JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_verifiers_directive ON directive_verifiers(directive_id); + +-- ============================================================================ +-- 7. APPROVALS -- human-in-the-loop gates +-- ============================================================================ +CREATE TABLE directive_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + step_id UUID REFERENCES chain_steps(id) ON DELETE SET NULL, + + -- Request + approval_type VARCHAR(64) NOT NULL, + description TEXT NOT NULL, + context JSONB, + urgency VARCHAR(16) NOT NULL DEFAULT 'normal', + + -- Response + status VARCHAR(32) NOT NULL DEFAULT 'pending', + response TEXT, + responded_by UUID, + responded_at TIMESTAMPTZ, + + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_approvals_directive ON directive_approvals(directive_id); +CREATE INDEX idx_approvals_status ON directive_approvals(status); diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index f9c981f..822b21f 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ Cli, CliConfig, Commands, ConfigCommand, ContractCommand, ChainCommand, - SupervisorCommand, ViewArgs, + DirectiveCommand, SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -32,6 +32,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, Commands::Chain(cmd) => run_chain(cmd).await, + Commands::Directive(cmd) => run_directive(cmd).await, } } @@ -1021,6 +1022,154 @@ async fn run_chain( Ok(()) } +/// Run directive commands. +async fn run_directive( + cmd: DirectiveCommand, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + match cmd { + DirectiveCommand::Create(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .create_directive(&args.goal, args.repository.as_deref(), &args.autonomy) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Status(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.get_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::List(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .list_directives(args.status.as_deref(), args.limit) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Steps(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.get_directive_chain(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Graph(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client.get_directive_graph(args.directive_id).await?; + + if args.with_status { + // Enhanced ASCII visualization with status + if let Some(nodes) = result.0.get("nodes").and_then(|v| v.as_array()) { + let mut by_depth: std::collections::HashMap<i32, Vec<(&str, &str)>> = + std::collections::HashMap::new(); + + for node in nodes { + let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let status = node + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); + let depth = node.get("depth").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + by_depth.entry(depth).or_default().push((name, status)); + } + + let directive_name = result + .0 + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Directive"); + println!("Directive: {}", directive_name); + println!(); + + let max_depth = by_depth.keys().max().copied().unwrap_or(0); + for depth in 0..=max_depth { + if let Some(steps) = by_depth.get(&depth) { + let indent = " ".repeat(depth as usize); + for (name, status) in steps { + let status_icon = match *status { + "passed" | "completed" => "\u{2713}", + "running" | "evaluating" => "\u{21bb}", + "failed" | "blocked" => "\u{2717}", + "rework" => "\u{21ba}", + "skipped" => "\u{2212}", + "ready" => "\u{25b7}", + _ => "\u{25cb}", + }; + println!("{}[{}] {} {}", indent, name, status_icon, status); + } + if depth < max_depth { + println!("{} |", indent); + println!("{} v", indent); + } + } + } + } + } else { + println!("{}", serde_json::to_string_pretty(&result.0)?); + } + } + DirectiveCommand::Events(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .list_directive_events(args.directive_id, args.limit) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Approve(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .approve_directive_request( + args.directive_id, + args.approval_id, + args.response.as_deref(), + ) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Deny(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .deny_directive_request( + args.directive_id, + args.approval_id, + args.reason.as_deref(), + ) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Start(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Starting directive {}...", args.directive_id); + let result = client.start_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Pause(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Pausing directive {}...", args.directive_id); + let result = client.pause_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Resume(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Resuming directive {}...", args.directive_id); + let result = client.resume_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Stop(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Stopping directive {}...", args.directive_id); + let result = client.stop_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::Archive(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + eprintln!("Archiving directive {}...", args.directive_id); + let result = client.archive_directive(args.directive_id).await?; + println!("{}", serde_json::to_string(&result.0)?); + } + } + + Ok(()) +} + /// Load contracts from API async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { let result = client.list_contracts().await?; diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs new file mode 100644 index 0000000..5281d21 --- /dev/null +++ b/makima/src/daemon/api/directive.rs @@ -0,0 +1,162 @@ +//! Directive API methods. + +use uuid::Uuid; + +use super::client::{ApiClient, ApiError}; +use super::supervisor::JsonValue; + +impl ApiClient { + /// Create a new directive. + pub async fn create_directive( + &self, + goal: &str, + repository_url: Option<&str>, + autonomy_level: &str, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct CreateRequest<'a> { + goal: &'a str, + repository_url: Option<&'a str>, + autonomy_level: &'a str, + } + let req = CreateRequest { + goal, + repository_url, + autonomy_level, + }; + self.post("/api/v1/directives", &req).await + } + + /// List all directives for the authenticated user. + pub async fn list_directives( + &self, + status: Option<&str>, + limit: i32, + ) -> Result<JsonValue, ApiError> { + let mut params = Vec::new(); + if let Some(s) = status { + params.push(format!("status={}", s)); + } + params.push(format!("limit={}", limit)); + let query_string = format!("?{}", params.join("&")); + self.get(&format!("/api/v1/directives{}", query_string)) + .await + } + + /// Get a directive by ID (includes progress info). + pub async fn get_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}", directive_id)) + .await + } + + /// Archive a directive. + pub async fn archive_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.delete_with_response(&format!("/api/v1/directives/{}", directive_id)) + .await + } + + /// Start a directive (plans and begins execution). + pub async fn start_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/start", directive_id)) + .await + } + + /// Pause a directive. + pub async fn pause_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/pause", directive_id)) + .await + } + + /// Resume a paused directive. + pub async fn resume_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/resume", directive_id)) + .await + } + + /// Stop a directive. + pub async fn stop_directive(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.post_empty(&format!("/api/v1/directives/{}/stop", directive_id)) + .await + } + + /// Get the current chain and steps for a directive. + pub async fn get_directive_chain(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}/chain", directive_id)) + .await + } + + /// Get directive DAG structure for visualization. + pub async fn get_directive_graph(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}/chain/graph", directive_id)) + .await + } + + /// List events for a directive. + pub async fn list_directive_events( + &self, + directive_id: Uuid, + limit: i32, + ) -> Result<JsonValue, ApiError> { + self.get(&format!( + "/api/v1/directives/{}/events?limit={}", + directive_id, limit + )) + .await + } + + /// List pending approvals for a directive. + pub async fn list_directive_approvals( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}/approvals", directive_id)) + .await + } + + /// Approve an approval request. + pub async fn approve_directive_request( + &self, + directive_id: Uuid, + approval_id: Uuid, + response: Option<&str>, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct ApprovalRequest<'a> { + response: Option<&'a str>, + } + let req = ApprovalRequest { response }; + self.post( + &format!( + "/api/v1/directives/{}/approvals/{}/approve", + directive_id, approval_id + ), + &req, + ) + .await + } + + /// Deny an approval request. + pub async fn deny_directive_request( + &self, + directive_id: Uuid, + approval_id: Uuid, + response: Option<&str>, + ) -> Result<JsonValue, ApiError> { + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct ApprovalRequest<'a> { + response: Option<&'a str>, + } + let req = ApprovalRequest { response }; + self.post( + &format!( + "/api/v1/directives/{}/approvals/{}/deny", + directive_id, approval_id + ), + &req, + ) + .await + } +} diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs index 7868907..f1f52d0 100644 --- a/makima/src/daemon/api/mod.rs +++ b/makima/src/daemon/api/mod.rs @@ -3,6 +3,7 @@ pub mod chain; pub mod client; pub mod contract; +pub mod directive; pub mod supervisor; pub use client::ApiClient; diff --git a/makima/src/daemon/chain/parser.rs b/makima/src/daemon/chain/parser.rs index 3851d1f..b32d0f2 100644 --- a/makima/src/daemon/chain/parser.rs +++ b/makima/src/daemon/chain/parser.rs @@ -395,7 +395,9 @@ contracts: fn test_repo_alias() { let yaml = r#" name: Repo Chain -repo: https://github.com/user/project +repositories: + - name: main + repository_url: https://github.com/user/project contracts: - name: Phase1 tasks: @@ -403,8 +405,9 @@ contracts: plan: "Work on repo" "#; let chain = parse_chain_yaml(yaml).unwrap(); + assert_eq!(chain.repositories.len(), 1); assert_eq!( - chain.repository_url, + chain.repositories[0].repository_url, Some("https://github.com/user/project".to_string()) ); } diff --git a/makima/src/daemon/chain/runner.rs b/makima/src/daemon/chain/runner.rs index dfbcfa7..1814581 100644 --- a/makima/src/daemon/chain/runner.rs +++ b/makima/src/daemon/chain/runner.rs @@ -37,8 +37,10 @@ pub enum RunnerError { /// Chain runner for creating and managing chains. pub struct ChainRunner { /// Base API URL + #[allow(dead_code)] api_url: String, /// API key for authentication + #[allow(dead_code)] api_key: String, } @@ -116,6 +118,7 @@ impl ChainRunner { CreateChainRequest { name: chain.name.clone(), description: chain.description.clone(), + repository_url: None, // Legacy field, repositories take precedence repositories: if repositories.is_empty() { None } else { @@ -242,7 +245,9 @@ mod tests { let yaml = r#" name: Test Chain description: A test chain -repo: https://github.com/test/repo +repositories: + - name: main + repository_url: https://github.com/test/repo contracts: - name: Research type: simple @@ -270,8 +275,11 @@ loop: assert_eq!(request.name, "Test Chain"); assert_eq!(request.description, Some("A test chain".to_string())); + // Repositories are now in a separate array + let repos = request.repositories.unwrap(); + assert_eq!(repos.len(), 1); assert_eq!( - request.repository_url, + repos[0].repository_url, Some("https://github.com/test/repo".to_string()) ); assert_eq!(request.loop_enabled, Some(true)); diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs new file mode 100644 index 0000000..a2bb34b --- /dev/null +++ b/makima/src/daemon/cli/directive.rs @@ -0,0 +1,186 @@ +//! Directive CLI commands for autonomous goal-driven orchestration. +//! +//! Directives are top-level goals that the system works toward. Each directive +//! generates a chain of steps that are executed autonomously with configurable +//! guardrails. + +use clap::Args; +use uuid::Uuid; + +/// Common arguments for directive commands requiring API access. +#[derive(Args, Debug, Clone)] +pub struct DirectiveArgs { + /// API URL + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY", global = true)] + pub api_key: String, +} + +/// Arguments for the `create` command. +#[derive(Args, Debug)] +pub struct CreateArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// The goal for the directive + #[arg(short, long)] + pub goal: String, + + /// Repository URL (optional) + #[arg(short, long)] + pub repository: Option<String>, + + /// Autonomy level: full_auto, guardrails, or manual + #[arg(short, long, default_value = "guardrails")] + pub autonomy: String, +} + +/// Arguments for the `status` command. +#[derive(Args, Debug)] +pub struct StatusArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `list` command. +#[derive(Args, Debug)] +pub struct ListArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Filter by status (draft, planning, active, paused, completed, archived, failed) + #[arg(long)] + pub status: Option<String>, + + /// Limit number of results + #[arg(long, default_value = "50")] + pub limit: i32, +} + +/// Arguments for the `steps` command. +#[derive(Args, Debug)] +pub struct StepsArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `events` command. +#[derive(Args, Debug)] +pub struct EventsArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, + + /// Limit number of events + #[arg(long, default_value = "50")] + pub limit: i32, +} + +/// Arguments for the `approve` command. +#[derive(Args, Debug)] +pub struct ApproveArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, + + /// Approval ID + pub approval_id: Uuid, + + /// Response message (optional) + #[arg(short, long)] + pub response: Option<String>, +} + +/// Arguments for the `deny` command. +#[derive(Args, Debug)] +pub struct DenyArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, + + /// Approval ID + pub approval_id: Uuid, + + /// Reason for denial (optional) + #[arg(short, long)] + pub reason: Option<String>, +} + +/// Arguments for the `start` command. +#[derive(Args, Debug)] +pub struct StartArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `pause` command. +#[derive(Args, Debug)] +pub struct PauseArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `resume` command. +#[derive(Args, Debug)] +pub struct ResumeArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `stop` command. +#[derive(Args, Debug)] +pub struct StopArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `archive` command. +#[derive(Args, Debug)] +pub struct ArchiveArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, +} + +/// Arguments for the `graph` command (ASCII DAG visualization). +#[derive(Args, Debug)] +pub struct GraphArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Directive ID + pub directive_id: Uuid, + + /// Show step status in nodes + #[arg(long)] + pub with_status: bool, +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 035a784..91ef87c 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -4,6 +4,7 @@ pub mod chain; pub mod config; pub mod contract; pub mod daemon; +pub mod directive; pub mod server; pub mod supervisor; pub mod view; @@ -14,6 +15,7 @@ pub use chain::ChainArgs; pub use config::CliConfig; pub use contract::ContractArgs; pub use daemon::DaemonArgs; +pub use directive::DirectiveArgs; pub use server::ServerArgs; pub use supervisor::SupervisorArgs; pub use view::ViewArgs; @@ -68,6 +70,14 @@ pub enum Commands { /// in parallel when no dependencies exist. #[command(subcommand)] Chain(ChainCommand), + + /// Directive commands for autonomous goal-driven orchestration + /// + /// Directives are top-level goals that generate chains of steps executed + /// autonomously with configurable guardrails. Steps spawn contracts with + /// supervisors and are verified with programmatic and LLM evaluation. + #[command(subcommand)] + Directive(DirectiveCommand), } /// Config subcommands for CLI configuration. @@ -248,6 +258,52 @@ pub enum ChainCommand { Archive(chain::ArchiveArgs), } +/// Directive subcommands for autonomous goal-driven orchestration. +#[derive(Subcommand, Debug)] +pub enum DirectiveCommand { + /// Create a new directive from a goal + Create(directive::CreateArgs), + + /// Get directive status and progress + Status(directive::StatusArgs), + + /// List all directives + List(directive::ListArgs), + + /// List steps in the directive's chain + Steps(directive::StepsArgs), + + /// Display ASCII DAG visualization + /// + /// Shows the directive's chain structure as an ASCII graph with + /// steps as nodes and dependencies as edges. + Graph(directive::GraphArgs), + + /// Show recent events for a directive + Events(directive::EventsArgs), + + /// Approve a pending approval request + Approve(directive::ApproveArgs), + + /// Deny a pending approval request + Deny(directive::DenyArgs), + + /// Start a directive (generates chain and begins execution) + Start(directive::StartArgs), + + /// Pause a running directive + Pause(directive::PauseArgs), + + /// Resume a paused directive + Resume(directive::ResumeArgs), + + /// Stop a directive + Stop(directive::StopArgs), + + /// Archive a directive + Archive(directive::ArchiveArgs), +} + impl Cli { /// Parse command-line arguments pub fn parse_args() -> Self { diff --git a/makima/src/daemon/db/local.rs b/makima/src/daemon/db/local.rs index f3ed45a..5b4ca5b 100644 --- a/makima/src/daemon/db/local.rs +++ b/makima/src/daemon/db/local.rs @@ -336,7 +336,9 @@ impl LocalDb { #[cfg(test)] mod tests { - use crate::daemon::*; + use super::*; + use chrono::Utc; + use uuid::Uuid; #[test] fn test_open_memory() { diff --git a/makima/src/daemon/process/claude_protocol.rs b/makima/src/daemon/process/claude_protocol.rs index 96e5377..930152b 100644 --- a/makima/src/daemon/process/claude_protocol.rs +++ b/makima/src/daemon/process/claude_protocol.rs @@ -45,7 +45,7 @@ impl ClaudeInputMessage { #[cfg(test)] mod tests { - use crate::daemon::*; + use super::*; #[test] fn test_user_message_serialization() { diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md new file mode 100644 index 0000000..97e8e20 --- /dev/null +++ b/makima/src/daemon/skills/directive.md @@ -0,0 +1,303 @@ +--- +name: makima-directive +description: Directive orchestration tools for autonomous goal-driven execution. Use when working with directives, chains, steps, verifiers, and approvals. +--- + +# Directive Orchestration Tools + +Directives are top-level goals that drive autonomous execution with configurable guardrails. Each directive generates a chain of steps that spawn contracts with supervisors, verified by programmatic checks and LLM evaluation. + +## Architecture + +``` +Directive (goal + requirements + acceptance criteria) + | + +-- Chain (generated DAG execution plan) + | +-- Step 1 (pending -> ready -> running -> evaluating -> passed) + | | +-- Contract (spawned when step reaches 'ready') + | | +-- Supervisor Task + | +-- Step 2 (depends_on: [Step 1]) + | +-- Step 3 (depends_on: [Step 1], parallel with Step 2) + | + +-- Verifiers (test runner, linter, build, type checker) + +-- Evaluations (programmatic + LLM composite scores) + +-- Events (audit stream) + +-- Approvals (human-in-the-loop gates) +``` + +## Status Flow + +### Directive Status +- `draft` - Created but not started +- `planning` - Generating chain from requirements +- `active` - Executing steps +- `paused` - Temporarily stopped +- `completed` - All steps passed +- `archived` - No longer active +- `failed` - Execution failed + +### Step Status +- `pending` - Waiting for dependencies +- `ready` - Dependencies met, ready to start +- `running` - Contract executing +- `evaluating` - Running verifiers +- `passed` - Evaluation succeeded +- `failed` - Evaluation failed, exceeded retries +- `rework` - Sent back for corrections +- `skipped` - Manually skipped +- `blocked` - Blocked by failed dependency + +## Autonomy Levels + +- `full_auto` - No approval gates, automatic progression +- `guardrails` - Request approval for yellow/red confidence scores +- `manual` - Request approval for all step completions + +## Confidence Scoring + +Each step evaluation produces a composite confidence score: + +1. **Programmatic verifiers** run first (tests, lint, build) + - Weight: 1.0 each + - If any required verifier fails: automatic RED + +2. **LLM evaluation** runs second + - Weight: 2.0 + - Evaluates against acceptance criteria + +3. **Composite score** computed from weighted average + - GREEN: >= configured threshold (default 0.8) + - YELLOW: >= yellow threshold (default 0.5) + - RED: below yellow threshold + +## CLI Commands + +```bash +# Create a new directive +makima directive create --goal "Add OAuth2 authentication" --repository https://github.com/org/repo + +# List directives +makima directive list [--status active] + +# Get directive status with progress +makima directive status <directive-id> + +# Start execution (generates chain and begins) +makima directive start <directive-id> + +# View chain steps +makima directive steps <directive-id> + +# View DAG visualization +makima directive graph <directive-id> --with-status + +# View recent events +makima directive events <directive-id> --limit 20 + +# Approve a pending request +makima directive approve <directive-id> <approval-id> [--response "Looks good"] + +# Deny a pending request +makima directive deny <directive-id> <approval-id> [--reason "Need more testing"] + +# Lifecycle commands +makima directive pause <directive-id> +makima directive resume <directive-id> +makima directive stop <directive-id> +makima directive archive <directive-id> +``` + +## API Endpoints + +### Directive CRUD +``` +POST /api/v1/directives # Create from goal +GET /api/v1/directives # List +GET /api/v1/directives/:id # Get with progress +PUT /api/v1/directives/:id # Update +DELETE /api/v1/directives/:id # Archive +``` + +### Lifecycle +``` +POST /api/v1/directives/:id/start # Plan + execute +POST /api/v1/directives/:id/pause # Pause +POST /api/v1/directives/:id/resume # Resume +POST /api/v1/directives/:id/stop # Stop +``` + +### Chain & Steps +``` +GET /api/v1/directives/:id/chain # Current chain + steps +GET /api/v1/directives/:id/chain/graph # DAG for visualization +POST /api/v1/directives/:id/chain/replan # Force regeneration +POST /api/v1/directives/:id/chain/steps # Add step +PUT /api/v1/directives/:id/chain/steps/:sid # Modify step +DELETE /api/v1/directives/:id/chain/steps/:sid # Remove step +``` + +### Step Operations +``` +GET /api/v1/directives/:id/steps/:sid # Step detail +POST /api/v1/directives/:id/steps/:sid/evaluate # Force re-evaluation +POST /api/v1/directives/:id/steps/:sid/skip # Skip step +POST /api/v1/directives/:id/steps/:sid/rework # Manual rework +``` + +### Monitoring +``` +GET /api/v1/directives/:id/evaluations # List evaluations +GET /api/v1/directives/:id/events # Event log (polling) +GET /api/v1/directives/:id/events/stream # Event stream (SSE) +``` + +### Verifiers +``` +GET /api/v1/directives/:id/verifiers # List verifiers +POST /api/v1/directives/:id/verifiers # Add verifier +PUT /api/v1/directives/:id/verifiers/:vid # Update verifier +POST /api/v1/directives/:id/verifiers/auto-detect # Auto-detect +``` + +### Approvals +``` +GET /api/v1/directives/:id/approvals # Pending approvals +POST /api/v1/directives/:id/approvals/:aid/approve # Approve +POST /api/v1/directives/:id/approvals/:aid/deny # Deny +``` + +## Creating a Directive + +### Request +```json +POST /api/v1/directives +{ + "goal": "Implement user authentication with OAuth2", + "repositoryUrl": "https://github.com/org/repo", + "autonomyLevel": "guardrails", + "confidenceThresholdGreen": 0.8, + "confidenceThresholdYellow": 0.5, + "maxReworkCycles": 3, + "maxTotalCostUsd": 100.0, + "maxWallTimeMinutes": 480 +} +``` + +### Response +```json +{ + "id": "uuid", + "title": "Implement user authentication with OAuth2", + "goal": "Implement user authentication with OAuth2", + "status": "draft", + "autonomyLevel": "guardrails", + "createdAt": "2026-02-05T12:00:00Z" +} +``` + +## Starting a Directive + +When you start a directive: +1. System generates requirements from the goal +2. Chain planner creates a DAG of steps +3. Root steps (no dependencies) transition to `ready` +4. Contracts spawn for ready steps with supervisors +5. Verifiers auto-detect from repository + +## Evaluation Flow + +When a contract completes: + +1. Step transitions to `evaluating` +2. **Programmatic verifiers** run (tests, lint, build) + - Each produces pass/fail + output +3. **LLM evaluation** runs + - Reviews code against acceptance criteria + - Provides feedback and score +4. **Composite score** computed +5. Based on confidence level and autonomy: + - GREEN: Step passes, downstream unblocks + - YELLOW (guardrails): Request approval + - RED: Initiate rework or request approval + +## Rework Flow + +When a step needs rework: + +1. Contract phase reset to editing +2. Supervisor receives rework instructions +3. Rework count incremented +4. If max reworks exceeded: escalate or fail + +## Event Types + +Events are logged for audit and monitoring: + +- `directive_created`, `directive_started`, `directive_paused`, `directive_completed` +- `chain_generated`, `chain_regenerated` +- `step_ready`, `step_started`, `step_evaluating`, `step_passed`, `step_failed` +- `rework_initiated`, `rework_completed` +- `approval_requested`, `approval_granted`, `approval_denied` +- `verifier_run`, `evaluation_completed` +- `circuit_breaker_triggered` + +## Verifier Configuration + +Verifiers can be auto-detected or manually configured: + +```json +POST /api/v1/directives/:id/verifiers +{ + "name": "Test Runner", + "verifierType": "test_runner", + "command": "npm test", + "workingDirectory": ".", + "timeoutSeconds": 300, + "weight": 1.0, + "required": true, + "enabled": true +} +``` + +### Auto-Detection + +The system detects verifiers from: +- `package.json` - npm test, npm run lint, npm run build +- `Cargo.toml` - cargo test, cargo clippy, cargo build +- `pyproject.toml` - pytest, ruff, mypy + +## Circuit Breakers + +Directives have built-in circuit breakers: + +- `maxTotalCostUsd` - Stop if cumulative cost exceeds limit +- `maxWallTimeMinutes` - Stop if elapsed time exceeds limit +- `maxReworkCycles` - Fail step after N rework attempts +- `maxChainRegenerations` - Fail if chain regenerated too many times + +## Example Workflow + +```bash +# 1. Create a directive +makima directive create \ + --goal "Add dark mode to the application" \ + --repository https://github.com/myorg/myapp \ + --autonomy guardrails + +# Returns directive ID: 123e4567-e89b-12d3-a456-426614174000 + +# 2. Start execution +makima directive start 123e4567-e89b-12d3-a456-426614174000 + +# 3. Monitor progress +makima directive status 123e4567-e89b-12d3-a456-426614174000 + +# 4. View the execution graph +makima directive graph 123e4567-e89b-12d3-a456-426614174000 --with-status + +# 5. Watch events +makima directive events 123e4567-e89b-12d3-a456-426614174000 + +# 6. If approval needed, approve or deny +makima directive approve 123e4567-e89b-12d3-a456-426614174000 <approval-id> +``` diff --git a/makima/src/daemon/skills/mod.rs b/makima/src/daemon/skills/mod.rs index 3b0c0dc..dafa9ec 100644 --- a/makima/src/daemon/skills/mod.rs +++ b/makima/src/daemon/skills/mod.rs @@ -9,12 +9,16 @@ pub const SUPERVISOR_SKILL: &str = include_str!("supervisor.md"); /// Contract skill content - task-contract interaction commands pub const CONTRACT_SKILL: &str = include_str!("contract.md"); -/// Chain skill content - multi-contract orchestration commands +/// Chain skill content - multi-contract orchestration commands (legacy) pub const CHAIN_SKILL: &str = include_str!("chain.md"); +/// Directive skill content - autonomous goal-driven orchestration +pub const DIRECTIVE_SKILL: &str = include_str!("directive.md"); + /// All skills as (name, content) pairs for installation pub const ALL_SKILLS: &[(&str, &str)] = &[ ("makima-supervisor", SUPERVISOR_SKILL), ("makima-contract", CONTRACT_SKILL), ("makima-chain", CHAIN_SKILL), + ("makima-directive", DIRECTIVE_SKILL), ]; diff --git a/makima/src/daemon/storage/patch.rs b/makima/src/daemon/storage/patch.rs index 0da4eda..b374d15 100644 --- a/makima/src/daemon/storage/patch.rs +++ b/makima/src/daemon/storage/patch.rs @@ -227,6 +227,16 @@ pub async fn create_export_patch( None }; + // Get current HEAD SHA for comparison + let head_sha = Command::new("git") + .current_dir(worktree_path) + .args(["rev-parse", "HEAD"]) + .output() + .await + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + // If we couldn't find upstream, try common default branches let base = if base.is_none() { let default_branches = ["origin/main", "origin/master", "main", "master"]; @@ -241,14 +251,23 @@ pub async fn create_export_patch( if let Ok(output) = merge_base { if output.status.success() { - found_base = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); - break; + let mb_sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Skip if merge-base equals HEAD (would result in empty diff) + if head_sha.as_ref() != Some(&mb_sha) { + found_base = Some(mb_sha); + break; + } } } } found_base } else { - base + // Also check upstream base + if base.as_ref() == head_sha.as_ref() { + None + } else { + base + } }; // If still nothing, get the first commit or use HEAD~1 diff --git a/makima/src/daemon/task/completion_gate.rs b/makima/src/daemon/task/completion_gate.rs index 69b7c6a..40a6466 100644 --- a/makima/src/daemon/task/completion_gate.rs +++ b/makima/src/daemon/task/completion_gate.rs @@ -5,7 +5,7 @@ //! development framework. //! //! Format: -//! ``` +//! ```text //! <COMPLETION_GATE> //! ready: true|false //! reason: "explanation of completion status" @@ -133,19 +133,18 @@ impl CompletionGate { /// This is useful when Claude produces multiple completion gates during /// a long-running task, and we want to use the final status. pub fn parse_last(text: &str) -> Option<Self> { + let start_tag = "<COMPLETION_GATE>"; let end_tag = "</COMPLETION_GATE>"; - let mut last_gate = None; - let mut search_start = 0; - while let Some(end_idx) = text[search_start..].find(end_tag) { - let absolute_end = search_start + end_idx + end_tag.len(); - if let Some(gate) = Self::parse(&text[..absolute_end]) { - last_gate = Some(gate); - } - search_start = absolute_end; - } + // Find the last occurrence of the start tag + let start_idx = text.rfind(start_tag)?; + let remaining = &text[start_idx..]; + + // Find the end tag after the last start tag + let end_idx = remaining.find(end_tag)?; - last_gate + // Parse just this last gate + Self::parse(&remaining[..end_idx + end_tag.len()]) } } diff --git a/makima/src/daemon/task/state.rs b/makima/src/daemon/task/state.rs index 7b59b62..fe73de1 100644 --- a/makima/src/daemon/task/state.rs +++ b/makima/src/daemon/task/state.rs @@ -124,9 +124,7 @@ impl Default for TaskState { #[cfg(test)] mod tests { - #[allow(unused_imports)] - use crate::daemon::*; - use super::TaskState; + use super::*; #[test] fn test_valid_transitions() { diff --git a/makima/src/daemon/temp.rs b/makima/src/daemon/temp.rs index 42d4a28..015b21b 100644 --- a/makima/src/daemon/temp.rs +++ b/makima/src/daemon/temp.rs @@ -214,7 +214,7 @@ impl Default for TempManager { #[cfg(test)] mod tests { - use crate::daemon::*; + use super::*; #[test] fn test_temp_manager_default_dir() { diff --git a/makima/src/daemon/worktree/manager.rs b/makima/src/daemon/worktree/manager.rs index 166e654..310627c 100644 --- a/makima/src/daemon/worktree/manager.rs +++ b/makima/src/daemon/worktree/manager.rs @@ -1949,7 +1949,8 @@ pub fn sanitize_name(name: &str) -> String { #[cfg(test)] mod tests { - use crate::daemon::*; + use super::*; + use uuid::Uuid; #[test] fn test_extract_repo_name() { diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index 5c88038..574e864 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -907,7 +907,7 @@ impl DaemonMessage { #[cfg(test)] mod tests { - use crate::daemon::*; + use super::*; #[test] fn test_daemon_message_serialization() { @@ -920,7 +920,7 @@ mod tests { #[test] fn test_daemon_command_deserialization() { - let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Build the feature","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":false}"#; + let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","taskName":"Build Feature","plan":"Build the feature","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":false}"#; let cmd: DaemonCommand = serde_json::from_str(json).unwrap(); match cmd { DaemonCommand::SpawnTask { @@ -945,7 +945,7 @@ mod tests { #[test] fn test_orchestrator_spawn_deserialization() { - let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Coordinate subtasks","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":true}"#; + let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","taskName":"Coordinate","plan":"Coordinate subtasks","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":true}"#; let cmd: DaemonCommand = serde_json::from_str(json).unwrap(); match cmd { DaemonCommand::SpawnTask { diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index e861f1d..3a96165 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1446,16 +1446,16 @@ pub struct Contract { /// Use `get_phase_config()` to get the parsed PhaseConfig. #[serde(skip_serializing_if = "Option::is_none")] pub phase_config: Option<serde_json::Value>, - /// Chain ID if this contract is part of a chain (DAG of contracts) + /// Directive ID if this contract is part of a directive's chain #[serde(skip_serializing_if = "Option::is_none")] - pub chain_id: Option<Uuid>, - /// Reference to chain spawned by this directive contract - #[serde(skip_serializing_if = "Option::is_none")] - pub spawned_chain_id: Option<Uuid>, - /// Whether this contract is a chain directive orchestrator + pub directive_id: Option<Uuid>, + /// Whether this contract is a directive orchestrator #[serde(default)] #[sqlx(default)] - pub is_chain_directive: bool, + pub is_directive_orchestrator: bool, + /// Reference to directive spawned by this orchestrator contract + #[serde(skip_serializing_if = "Option::is_none")] + pub spawned_directive_id: Option<Uuid>, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2596,914 +2596,648 @@ pub struct HeartbeatHistoryQuery { } // ============================================================================= -// Chains (DAG of contracts for multi-contract orchestration) +// Directives (Goal-driven orchestration with chains of steps) // ============================================================================= -/// Chain status determines the overall state of the chain +/// Directive status #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum ChainStatus { - /// Chain is actively running +#[serde(rename_all = "snake_case")] +pub enum DirectiveStatus { + Draft, + Planning, Active, - /// All contracts completed successfully + Paused, Completed, - /// Chain was manually archived Archived, + Failed, } -impl Default for ChainStatus { +impl Default for DirectiveStatus { fn default() -> Self { - ChainStatus::Active + DirectiveStatus::Draft } } -impl std::fmt::Display for ChainStatus { +impl std::fmt::Display for DirectiveStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ChainStatus::Active => write!(f, "active"), - ChainStatus::Completed => write!(f, "completed"), - ChainStatus::Archived => write!(f, "archived"), + DirectiveStatus::Draft => write!(f, "draft"), + DirectiveStatus::Planning => write!(f, "planning"), + DirectiveStatus::Active => write!(f, "active"), + DirectiveStatus::Paused => write!(f, "paused"), + DirectiveStatus::Completed => write!(f, "completed"), + DirectiveStatus::Archived => write!(f, "archived"), + DirectiveStatus::Failed => write!(f, "failed"), } } } -impl std::str::FromStr for ChainStatus { +impl std::str::FromStr for DirectiveStatus { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { match s.to_lowercase().as_str() { - "active" => Ok(ChainStatus::Active), - "completed" => Ok(ChainStatus::Completed), - "archived" => Ok(ChainStatus::Archived), - _ => Err(format!("Invalid chain status: {}", s)), + "draft" => Ok(DirectiveStatus::Draft), + "planning" => Ok(DirectiveStatus::Planning), + "active" => Ok(DirectiveStatus::Active), + "paused" => Ok(DirectiveStatus::Paused), + "completed" => Ok(DirectiveStatus::Completed), + "archived" => Ok(DirectiveStatus::Archived), + "failed" => Ok(DirectiveStatus::Failed), + _ => Err(format!("Invalid directive status: {}", s)), } } } -/// Chain - a directed acyclic graph (DAG) of contracts -/// Fits Makima's control theme - she controls through invisible chains +/// Directive - the top-level goal-driven orchestration entity #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct Chain { +pub struct Directive { pub id: Uuid, pub owner_id: Uuid, - pub name: String, - pub description: Option<String>, + pub title: String, + pub goal: String, + /// Structured requirements: [{ id, title, description, priority, category }] + #[sqlx(json)] + pub requirements: serde_json::Value, + /// Acceptance criteria: [{ id, requirementIds, description, testable, verificationMethod }] + #[sqlx(json)] + pub acceptance_criteria: serde_json::Value, + /// Constraints: [{ id, type, description, impact }] + #[sqlx(json)] + pub constraints: serde_json::Value, + /// External dependencies: [{ id, name, type, status, requiredBy }] + #[sqlx(json)] + pub external_dependencies: serde_json::Value, pub status: String, - /// Whether loop mode is enabled for iterative execution - #[serde(default)] - pub loop_enabled: bool, - /// Maximum loop iterations (default: 10) - pub loop_max_iterations: Option<i32>, - /// Current loop iteration count - pub loop_current_iteration: Option<i32>, - /// Progress check prompt/criteria for evaluating loop completion - pub loop_progress_check: Option<String>, - /// Reference to the directive contract that created/orchestrates this chain - pub directive_contract_id: Option<Uuid>, - /// The directive document text (formal specification) - pub directive_document: Option<String>, - /// Whether LLM evaluation is enabled after contract completion - #[serde(default = "default_evaluation_enabled")] - #[sqlx(default)] - pub evaluation_enabled: bool, - /// Default pass threshold for evaluations (0.0-1.0) - pub default_pass_threshold: Option<f64>, - /// Default max retry attempts for evaluations - pub default_max_retries: Option<i32>, - /// Version for optimistic locking + pub autonomy_level: String, + pub confidence_threshold_green: f64, + pub confidence_threshold_yellow: f64, + pub max_total_cost_usd: Option<f64>, + pub max_wall_time_minutes: Option<i32>, + pub max_rework_cycles: Option<i32>, + pub max_chain_regenerations: Option<i32>, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, + pub orchestrator_contract_id: Option<Uuid>, + pub current_chain_id: Option<Uuid>, + pub chain_generation_count: i32, + pub total_cost_usd: f64, + pub started_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } -fn default_evaluation_enabled() -> bool { - true +impl Directive { + /// Parse status string to DirectiveStatus enum + pub fn status_enum(&self) -> Result<DirectiveStatus, String> { + self.status.parse() + } } -/// Chain repository record from the database +/// Directive chain - a generated execution plan (DAG) for a directive #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainRepository { +pub struct DirectiveChain { pub id: Uuid, - pub chain_id: Uuid, + pub directive_id: Uuid, + pub generation: i32, pub name: String, - pub repository_url: Option<String>, - pub local_path: Option<String>, - pub source_type: String, + pub description: Option<String>, + pub rationale: Option<String>, + pub planning_model: Option<String>, pub status: String, - pub is_primary: bool, + pub total_steps: i32, + pub completed_steps: i32, + pub failed_steps: i32, + pub current_confidence: Option<f64>, + pub started_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, + pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } -impl ChainRepository { - /// Parse source_type string to RepositorySourceType enum - pub fn source_type_enum(&self) -> Result<RepositorySourceType, String> { - self.source_type.parse() +/// Chain step status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum StepStatus { + Pending, + Ready, + Running, + Evaluating, + Passed, + Failed, + Rework, + Skipped, + Blocked, +} + +impl Default for StepStatus { + fn default() -> Self { + StepStatus::Pending } +} - /// Parse status string to RepositoryStatus enum - pub fn status_enum(&self) -> Result<RepositoryStatus, String> { - self.status.parse() +impl std::fmt::Display for StepStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StepStatus::Pending => write!(f, "pending"), + StepStatus::Ready => write!(f, "ready"), + StepStatus::Running => write!(f, "running"), + StepStatus::Evaluating => write!(f, "evaluating"), + StepStatus::Passed => write!(f, "passed"), + StepStatus::Failed => write!(f, "failed"), + StepStatus::Rework => write!(f, "rework"), + StepStatus::Skipped => write!(f, "skipped"), + StepStatus::Blocked => write!(f, "blocked"), + } } } -impl Chain { - /// Parse status string to ChainStatus enum - pub fn status_enum(&self) -> Result<ChainStatus, String> { - self.status.parse() +impl std::str::FromStr for StepStatus { + type Err = String; + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "pending" => Ok(StepStatus::Pending), + "ready" => Ok(StepStatus::Ready), + "running" => Ok(StepStatus::Running), + "evaluating" => Ok(StepStatus::Evaluating), + "passed" => Ok(StepStatus::Passed), + "failed" => Ok(StepStatus::Failed), + "rework" => Ok(StepStatus::Rework), + "skipped" => Ok(StepStatus::Skipped), + "blocked" => Ok(StepStatus::Blocked), + _ => Err(format!("Invalid step status: {}", s)), + } } } -/// Chain contract link - links contracts to chains with DAG dependency info +/// Chain step - a node in the DAG execution plan #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainContract { +pub struct ChainStep { pub id: Uuid, pub chain_id: Uuid, - pub contract_id: Uuid, - /// Contract IDs this contract depends on (DAG edges) + pub name: String, + pub description: Option<String>, + pub step_type: String, + pub contract_type: String, + pub initial_phase: Option<String>, + pub task_plan: Option<String>, #[sqlx(default)] - pub depends_on: Vec<Uuid>, - /// Order for display/processing (topological sort order) - pub order_index: i32, - /// X position for GUI editor - pub editor_x: Option<f64>, - /// Y position for GUI editor - pub editor_y: Option<f64>, - /// Evaluation status: pending, evaluating, passed, failed, rework, escalated - #[serde(default = "default_evaluation_status")] + pub phases: Vec<String>, #[sqlx(default)] - pub evaluation_status: String, - /// Number of evaluation retry attempts - #[serde(default)] + pub depends_on: Vec<Uuid>, + pub parallel_group: Option<String>, #[sqlx(default)] - pub evaluation_retry_count: i32, - /// Maximum evaluation retry attempts (default: 3) - #[serde(default = "default_max_evaluation_retries")] + pub requirement_ids: Vec<String>, #[sqlx(default)] - pub max_evaluation_retries: i32, - /// Reference to the last evaluation result + pub acceptance_criteria_ids: Vec<String>, + #[sqlx(json)] + #[serde(default)] + pub verifier_config: serde_json::Value, + pub status: String, + pub contract_id: Option<Uuid>, + pub supervisor_task_id: Option<Uuid>, + pub confidence_score: Option<f64>, + pub confidence_level: Option<String>, + pub evaluation_count: i32, + pub rework_count: i32, pub last_evaluation_id: Option<Uuid>, - /// Rework feedback/instructions from failed evaluation - pub rework_feedback: Option<String>, - /// When rework was started - pub rework_started_at: Option<DateTime<Utc>>, - /// When contract originally completed (before rework) - pub original_completion_at: Option<DateTime<Utc>>, + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, + pub order_index: i32, + pub started_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>, } -fn default_evaluation_status() -> String { - "pending".to_string() +impl ChainStep { + /// Parse status string to StepStatus enum + pub fn status_enum(&self) -> Result<StepStatus, String> { + self.status.parse() + } } -fn default_max_evaluation_retries() -> i32 { - 3 +/// Confidence level (traffic light) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ConfidenceLevel { + Green, + Yellow, + Red, +} + +impl ConfidenceLevel { + pub fn from_score(score: f64, green_threshold: f64, yellow_threshold: f64) -> Self { + if score >= green_threshold { + Self::Green + } else if score >= yellow_threshold { + Self::Yellow + } else { + Self::Red + } + } } -/// Chain event for audit trail +impl std::fmt::Display for ConfidenceLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfidenceLevel::Green => write!(f, "green"), + ConfidenceLevel::Yellow => write!(f, "yellow"), + ConfidenceLevel::Red => write!(f, "red"), + } + } +} + +/// Directive evaluation - composite programmatic + LLM evaluation result #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainEvent { +pub struct DirectiveEvaluation { pub id: Uuid, - pub chain_id: Uuid, - pub event_type: String, + pub directive_id: Uuid, + pub chain_id: Option<Uuid>, + pub step_id: Option<Uuid>, pub contract_id: Option<Uuid>, + pub evaluation_type: String, + pub evaluation_number: i32, + pub evaluator: Option<String>, + pub passed: bool, + pub overall_score: Option<f64>, + pub confidence_level: Option<String>, #[sqlx(json)] - pub event_data: Option<serde_json::Value>, + #[serde(default)] + pub programmatic_results: serde_json::Value, + #[sqlx(json)] + #[serde(default)] + pub llm_results: serde_json::Value, + #[sqlx(json)] + #[serde(default)] + pub criteria_results: serde_json::Value, + pub summary_feedback: String, + pub rework_instructions: Option<String>, + #[sqlx(json)] + pub directive_snapshot: Option<serde_json::Value>, + #[sqlx(json)] + pub deliverables_snapshot: Option<serde_json::Value>, + pub started_at: DateTime<Utc>, + pub completed_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>, } -/// Summary of a chain for list views -#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +/// Directive event - audit stream entry +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainSummary { +pub struct DirectiveEvent { pub id: Uuid, - pub name: String, - pub description: Option<String>, - pub status: String, - pub loop_enabled: bool, - pub loop_current_iteration: Option<i32>, - pub contract_count: i64, - pub completed_count: i64, - pub version: i32, + pub directive_id: Uuid, + pub chain_id: Option<Uuid>, + pub step_id: Option<Uuid>, + pub event_type: String, + pub severity: String, + #[sqlx(json)] + pub event_data: Option<serde_json::Value>, + pub actor_type: String, + pub actor_id: Option<Uuid>, pub created_at: DateTime<Utc>, } -/// Chain with contracts for detail view -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainWithContracts { - #[serde(flatten)] - pub chain: Chain, - pub contracts: Vec<ChainContractDetail>, - pub repositories: Vec<ChainRepository>, -} - -/// Contract detail within a chain (includes contract info + chain link info) -#[derive(Debug, Clone, FromRow, Serialize, ToSchema)] +/// Directive verifier - pluggable verification configuration +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainContractDetail { - pub chain_contract_id: Uuid, - pub contract_id: Uuid, - pub contract_name: String, - pub contract_status: String, - pub contract_phase: String, - #[sqlx(default)] - pub depends_on: Vec<Uuid>, - pub order_index: i32, - pub editor_x: Option<f64>, - pub editor_y: Option<f64>, - /// Evaluation status: pending, passed, failed, rework - #[sqlx(default)] - pub evaluation_status: Option<String>, - /// Number of evaluation retries - #[sqlx(default)] - pub evaluation_retry_count: i32, - /// Maximum evaluation retry attempts +pub struct DirectiveVerifier { + pub id: Uuid, + pub directive_id: Uuid, + pub name: String, + pub verifier_type: String, + pub command: Option<String>, + pub working_directory: Option<String>, + pub timeout_seconds: Option<i32>, + #[sqlx(json)] + #[serde(default)] + pub environment: serde_json::Value, + pub auto_detect: bool, #[sqlx(default)] - pub max_evaluation_retries: i32, - /// When the chain contract was created + pub detect_files: Vec<String>, + pub weight: f64, + pub required: bool, + pub enabled: bool, + pub last_run_at: Option<DateTime<Utc>>, + #[sqlx(json)] + pub last_result: Option<serde_json::Value>, pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, } -/// DAG graph structure for visualization -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainGraphResponse { - pub chain_id: Uuid, - pub chain_name: String, - pub chain_status: String, - pub nodes: Vec<ChainGraphNode>, - pub edges: Vec<ChainGraphEdge>, -} - -/// Node in chain DAG graph -#[derive(Debug, Serialize, ToSchema)] +/// Directive approval - human-in-the-loop gate +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainGraphNode { +pub struct DirectiveApproval { pub id: Uuid, - pub contract_id: Uuid, - pub name: String, + pub directive_id: Uuid, + pub step_id: Option<Uuid>, + pub approval_type: String, + pub description: String, + #[sqlx(json)] + pub context: Option<serde_json::Value>, + pub urgency: String, pub status: String, - pub phase: String, - pub x: f64, - pub y: f64, + pub response: Option<String>, + pub responded_by: Option<Uuid>, + pub responded_at: Option<DateTime<Utc>>, + pub expires_at: Option<DateTime<Utc>>, + pub created_at: DateTime<Utc>, } -/// Edge in chain DAG graph -#[derive(Debug, Clone, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainGraphEdge { - pub from: Uuid, - pub to: Uuid, -} +// ============================================================================= +// Directive Request/Response Types +// ============================================================================= -/// Response for chain list endpoint -#[derive(Debug, Serialize, ToSchema)] +/// Request to create a directive from a goal +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainListResponse { - pub chains: Vec<ChainSummary>, - pub total: i64, +pub struct CreateDirectiveRequest { + pub goal: String, + pub title: Option<String>, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub base_branch: Option<String>, + pub autonomy_level: Option<String>, + pub requirements: Option<serde_json::Value>, + pub acceptance_criteria: Option<serde_json::Value>, + pub confidence_threshold_green: Option<f64>, + pub confidence_threshold_yellow: Option<f64>, + pub max_total_cost_usd: Option<f64>, + pub max_wall_time_minutes: Option<i32>, } -/// Request payload for creating a new chain -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +/// Request to update a directive +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct CreateChainRequest { - /// Name of the chain - pub name: String, - /// Optional description - pub description: Option<String>, - /// Repositories for this chain - pub repositories: Option<Vec<AddChainRepositoryRequest>>, - /// Enable loop mode for iterative execution - #[serde(default)] - pub loop_enabled: Option<bool>, - /// Maximum loop iterations (default: 10) - pub loop_max_iterations: Option<i32>, - /// Progress check prompt for evaluating loop completion - pub loop_progress_check: Option<String>, - /// Contracts to create within this chain - pub contracts: Option<Vec<CreateChainContractRequest>>, +pub struct UpdateDirectiveRequest { + pub title: Option<String>, + pub goal: Option<String>, + pub requirements: Option<serde_json::Value>, + pub acceptance_criteria: Option<serde_json::Value>, + pub constraints: Option<serde_json::Value>, + pub external_dependencies: Option<serde_json::Value>, + pub autonomy_level: Option<String>, + pub confidence_threshold_green: Option<f64>, + pub confidence_threshold_yellow: Option<f64>, + pub max_total_cost_usd: Option<f64>, + pub max_wall_time_minutes: Option<i32>, + pub max_rework_cycles: Option<i32>, + pub max_chain_regenerations: Option<i32>, + pub version: i32, } -/// Request to add a repository to a chain -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +/// Directive summary for list views +#[derive(Debug, Clone, Serialize, FromRow, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct AddChainRepositoryRequest { - /// Display name for the repository - pub name: String, - /// Remote repository URL (for remote repos) - pub repository_url: Option<String>, - /// Local filesystem path (for local repos) - pub local_path: Option<String>, - /// Source type: remote, local, or managed - #[serde(default = "default_source_type")] - pub source_type: String, - /// Whether this is the primary repository - #[serde(default)] - pub is_primary: bool, +pub struct DirectiveSummary { + pub id: Uuid, + pub title: String, + pub goal: String, + pub status: String, + pub autonomy_level: String, + pub current_confidence: Option<f64>, + pub completed_steps: i32, + pub total_steps: i32, + pub chain_generation_count: i32, + pub started_at: Option<DateTime<Utc>>, + pub created_at: DateTime<Utc>, } -fn default_source_type() -> String { - "remote".to_string() +/// Directive with progress, chain, events, and approvals +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveWithProgress { + #[serde(flatten)] + pub directive: Directive, + pub chain: Option<DirectiveChain>, + pub steps: Vec<ChainStep>, + pub recent_events: Vec<DirectiveEvent>, + pub pending_approvals: Vec<DirectiveApproval>, } -/// Request to create a contract within a chain -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +/// Request to add a step to a chain +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct CreateChainContractRequest { - /// Name of the contract +pub struct AddStepRequest { pub name: String, - /// Optional description pub description: Option<String>, - /// Contract type - #[serde(default)] + pub step_type: Option<String>, pub contract_type: Option<String>, - /// Initial phase pub initial_phase: Option<String>, - /// Phases for the contract + pub task_plan: Option<String>, pub phases: Option<Vec<String>>, - /// Names of contracts this depends on (resolved to IDs) - pub depends_on: Option<Vec<String>>, - /// Tasks to create in this contract - pub tasks: Option<Vec<CreateChainTaskRequest>>, - /// Deliverables for this contract - pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, - /// Position in GUI editor + pub depends_on: Option<Vec<Uuid>>, + pub parallel_group: Option<String>, + pub requirement_ids: Option<Vec<String>>, + pub acceptance_criteria_ids: Option<Vec<String>>, + pub verifier_config: Option<serde_json::Value>, pub editor_x: Option<f64>, pub editor_y: Option<f64>, } -/// Task definition within a chain contract -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateChainTaskRequest { - pub name: String, - pub plan: String, -} - -/// Deliverable definition within a chain contract -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateChainDeliverableRequest { - pub id: String, - pub name: String, - pub priority: Option<String>, -} - -/// Validation configuration for checkpoint contracts. -/// Checkpoint contracts validate the outputs of their upstream dependencies -/// before allowing downstream contracts to proceed. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CheckpointValidation { - /// Check that all required deliverables from upstream contracts exist - #[serde(default)] - pub check_deliverables: bool, - - /// Run tests in the repository (requires repository to be configured) - #[serde(default)] - pub run_tests: bool, - - /// Custom validation instructions for Claude to execute. - /// Claude will review the outputs of upstream contracts and verify they meet these criteria. - pub check_content: Option<String>, - - /// Action to take on validation failure: "block" (default), "retry", "warn" - /// - block: Fail the checkpoint and block downstream contracts - /// - retry: Mark upstream contracts for retry (up to max_retries) - /// - warn: Log warning but allow downstream to proceed - #[serde(default = "default_checkpoint_on_failure")] - pub on_failure: String, - - /// Maximum retry attempts for upstream contracts (when on_failure = "retry") - #[serde(default = "default_checkpoint_max_retries")] - pub max_retries: i32, -} - -fn default_checkpoint_on_failure() -> String { - "block".to_string() -} - -fn default_checkpoint_max_retries() -> i32 { - 3 -} - -impl Default for CheckpointValidation { - fn default() -> Self { - Self { - check_deliverables: false, - run_tests: false, - check_content: None, - on_failure: default_checkpoint_on_failure(), - max_retries: default_checkpoint_max_retries(), - } - } -} - -/// Request to update an existing chain -#[derive(Debug, Clone, Deserialize, ToSchema)] +/// Request to update a step +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct UpdateChainRequest { +pub struct UpdateStepRequest { pub name: Option<String>, pub description: Option<String>, - pub status: Option<String>, - pub loop_enabled: Option<bool>, - pub loop_max_iterations: Option<i32>, - pub loop_progress_check: Option<String>, - /// Version for optimistic locking - pub version: Option<i32>, -} - -/// Request to add a contract to a chain -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddContractToChainRequest { - /// Existing contract ID to add - pub contract_id: Option<Uuid>, - /// Or create a new contract with this definition - pub new_contract: Option<CreateChainContractRequest>, - /// Contract IDs this depends on + pub task_plan: Option<String>, pub depends_on: Option<Vec<Uuid>>, - /// Position in GUI editor + pub requirement_ids: Option<Vec<String>>, + pub acceptance_criteria_ids: Option<Vec<String>>, + pub verifier_config: Option<serde_json::Value>, pub editor_x: Option<f64>, pub editor_y: Option<f64>, } -/// Editor data model for GUI chain editor -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainEditorData { - pub id: Option<Uuid>, - pub name: String, - pub description: Option<String>, - pub repositories: Vec<ChainRepository>, - pub loop_enabled: bool, - pub loop_max_iterations: Option<i32>, - pub loop_progress_check: Option<String>, - pub nodes: Vec<ChainEditorNode>, - pub edges: Vec<ChainEditorEdge>, -} - -/// Node in chain editor -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainEditorNode { - pub id: String, - pub x: f64, - pub y: f64, - pub contract: ChainEditorContract, -} - -/// Contract data in chain editor node -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainEditorContract { - pub name: String, - pub description: Option<String>, - #[serde(rename = "type")] - pub contract_type: String, - pub phases: Vec<String>, - pub tasks: Vec<ChainEditorTask>, - pub deliverables: Vec<ChainEditorDeliverable>, -} - -/// Task in chain editor -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainEditorTask { - pub name: String, - pub plan: String, -} - -/// Deliverable in chain editor -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainEditorDeliverable { - pub id: String, - pub name: String, - pub priority: String, -} - -/// Edge in chain editor -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChainEditorEdge { - pub from: String, - pub to: String, -} - -// ============================================================================= -// Chain Contract Definitions (stored specs for on-demand contract creation) -// ============================================================================= - -/// Contract definition within a chain - stored spec before actual contract is created -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +/// Chain graph response for DAG visualization +#[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainContractDefinition { - pub id: Uuid, +pub struct DirectiveChainGraphResponse { pub chain_id: Uuid, - pub name: String, - pub description: Option<String>, - pub contract_type: String, - pub initial_phase: Option<String>, - /// Names of other definitions this depends on - #[sqlx(default)] - pub depends_on_names: Vec<String>, - /// Task definitions as JSON: [{name, plan}, ...] - pub tasks: Option<serde_json::Value>, - /// Deliverable definitions as JSON: [{id, name, priority}, ...] - pub deliverables: Option<serde_json::Value>, - /// Validation configuration for checkpoint contracts (JSON) - pub validation: Option<serde_json::Value>, - /// Requirement IDs this contract addresses (for traceability) - #[sqlx(default)] - #[serde(default)] - pub requirement_ids: Vec<String>, - /// Acceptance criteria for this contract (JSON array) - #[serde(default)] - pub acceptance_criteria: Option<serde_json::Value>, - /// Whether LLM evaluation is enabled for this contract - #[serde(default = "default_evaluation_enabled")] - #[sqlx(default)] - pub evaluation_enabled: bool, - /// Pass threshold for evaluation (0.0-1.0) - pub pass_threshold: Option<f64>, - /// Position in GUI editor - pub editor_x: Option<f64>, - pub editor_y: Option<f64>, - pub order_index: i32, - pub created_at: DateTime<Utc>, + pub directive_id: Uuid, + pub nodes: Vec<DirectiveChainGraphNode>, + pub edges: Vec<DirectiveChainGraphEdge>, } -/// Request to add a contract definition to a chain -#[derive(Debug, Clone, Deserialize, ToSchema)] +/// Node in directive chain graph +#[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct AddContractDefinitionRequest { +pub struct DirectiveChainGraphNode { + pub id: Uuid, pub name: String, - pub description: Option<String>, - #[serde(default = "default_contract_type")] - pub contract_type: String, - pub initial_phase: Option<String>, - /// Names of other definitions this depends on - pub depends_on: Option<Vec<String>>, - /// Task definitions - pub tasks: Option<Vec<CreateChainTaskRequest>>, - /// Deliverable definitions - pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, - /// Validation configuration (for checkpoint contracts) - pub validation: Option<CheckpointValidation>, - /// Position in GUI editor - pub editor_x: Option<f64>, - pub editor_y: Option<f64>, -} - -fn default_contract_type() -> String { - "simple".to_string() -} - -/// Request to update a contract definition -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractDefinitionRequest { - pub name: Option<String>, - pub description: Option<String>, - pub contract_type: Option<String>, - pub initial_phase: Option<String>, - pub depends_on: Option<Vec<String>>, - pub tasks: Option<Vec<CreateChainTaskRequest>>, - pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, - /// Validation configuration (for checkpoint contracts) - pub validation: Option<CheckpointValidation>, + pub step_type: String, + pub status: String, + pub confidence_score: Option<f64>, + pub confidence_level: Option<String>, + pub contract_id: Option<Uuid>, pub editor_x: Option<f64>, pub editor_y: Option<f64>, } -/// Request to start a chain (kept for backwards compatibility) -#[derive(Debug, Clone, Deserialize, ToSchema)] +/// Edge in directive chain graph +#[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct StartChainRequest { - /// Repository URL (reserved for future use) - pub repository_url: Option<String>, +pub struct DirectiveChainGraphEdge { + pub source: Uuid, + pub target: Uuid, } -/// Response when starting a chain -#[derive(Debug, Clone, Serialize, ToSchema)] +/// Start directive response +#[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct StartChainResponse { +pub struct StartDirectiveResponse { + pub directive_id: Uuid, pub chain_id: Uuid, - /// Root contracts created (those with no dependencies) - pub contracts_created: Vec<Uuid>, + pub chain_generation: i32, + pub steps: Vec<ChainStep>, pub status: String, } -/// Graph node for definitions (before contracts are created) -#[derive(Debug, Clone, Serialize, ToSchema)] +/// Request to create a verifier +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainDefinitionGraphNode { - pub id: Uuid, +pub struct CreateVerifierRequest { pub name: String, - pub contract_type: String, - pub x: f64, - pub y: f64, - /// Whether this definition has been instantiated as a contract - pub is_instantiated: bool, - /// The contract ID if instantiated - pub contract_id: Option<Uuid>, - pub contract_status: Option<String>, + pub verifier_type: String, + pub command: Option<String>, + pub working_directory: Option<String>, + pub timeout_seconds: Option<i32>, + pub environment: Option<serde_json::Value>, + pub weight: Option<f64>, + pub required: Option<bool>, } -/// Graph response for definitions -#[derive(Debug, Clone, Serialize, ToSchema)] +/// Request to update a verifier +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainDefinitionGraphResponse { - pub chain_id: Uuid, - pub chain_name: String, - pub chain_status: String, - pub nodes: Vec<ChainDefinitionGraphNode>, - pub edges: Vec<ChainGraphEdge>, +pub struct UpdateVerifierRequest { + pub name: Option<String>, + pub command: Option<String>, + pub working_directory: Option<String>, + pub timeout_seconds: Option<i32>, + pub weight: Option<f64>, + pub required: Option<bool>, + pub enabled: Option<bool>, } -// ============================================================================= -// Chain Directives (formal specification documents for directive-driven chains) -// ============================================================================= - -/// Chain directive - formal specification document that drives chain creation and evaluation -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +/// Approval action request +#[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct ChainDirective { - pub id: Uuid, - pub chain_id: Uuid, - pub version: i32, - /// Requirements as JSON: [{ id, title, description, priority, category, parentId? }] - #[sqlx(json)] - pub requirements: serde_json::Value, - /// Acceptance criteria as JSON: [{ id, requirementIds[], description, testable, verificationMethod }] - #[sqlx(json)] - pub acceptance_criteria: serde_json::Value, - /// Constraints as JSON: [{ id, type, description, impact }] - #[sqlx(json)] - pub constraints: serde_json::Value, - /// External dependencies as JSON: [{ id, name, type, status, requiredBy[] }] - #[sqlx(json)] - pub external_dependencies: serde_json::Value, - /// Source type: 'manual', 'llm_generated', 'imported' - pub source_type: String, - pub created_at: DateTime<Utc>, - pub updated_at: DateTime<Utc>, +pub struct ApprovalActionRequest { + pub response: Option<String>, } -/// Requirement in a directive +/// Directive requirement (shared type used in directive specification) #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DirectiveRequirement { pub id: String, pub title: String, pub description: String, - /// Priority: 'must', 'should', 'could', 'wont' pub priority: String, - /// Category: 'feature', 'infrastructure', 'testing', etc. pub category: Option<String>, - /// Parent requirement ID for hierarchical requirements + #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option<String>, } -/// Acceptance criterion in a directive +/// Directive acceptance criterion #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DirectiveAcceptanceCriterion { pub id: String, - /// Requirement IDs this criterion validates + #[serde(default)] pub requirement_ids: Vec<String>, pub description: String, + #[serde(default = "default_true")] pub testable: bool, - /// Verification method: 'automated', 'manual', 'review', 'llm' - pub verification_method: String, + pub verification_method: Option<String>, } -/// Constraint in a directive -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DirectiveConstraint { - pub id: String, - /// Type: 'technical', 'business', 'time', 'resource' - #[serde(rename = "type")] - pub constraint_type: String, - pub description: String, - /// Impact: 'high', 'medium', 'low' - pub impact: String, +fn default_true() -> bool { + true } -/// External dependency in a directive -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DirectiveExternalDependency { - pub id: String, - pub name: String, - /// Type: 'api', 'service', 'library', 'data' - #[serde(rename = "type")] - pub dependency_type: String, - /// Status: 'available', 'pending', 'blocked' - pub status: String, - /// Requirement IDs that need this dependency - pub required_by: Vec<String>, -} +// Old chain types (Chain, ChainContract, ChainContractDefinition, ChainDirective, +// ContractEvaluation, ChainEvent, ChainRepository, etc.) have been replaced by +// the directive system above: Directive, DirectiveChain, ChainStep, +// DirectiveEvaluation, DirectiveEvent, DirectiveVerifier, DirectiveApproval. -/// Request to create or update a chain directive -#[derive(Debug, Clone, Deserialize, ToSchema)] +// Legacy types kept temporarily for chain runner/parser compatibility during migration. +// These will be removed once the chain daemon module is replaced. + +/// Request payload for creating a new chain (legacy - used by chain runner) +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct CreateChainDirectiveRequest { - pub requirements: Option<Vec<DirectiveRequirement>>, - pub acceptance_criteria: Option<Vec<DirectiveAcceptanceCriterion>>, - pub constraints: Option<Vec<DirectiveConstraint>>, - pub external_dependencies: Option<Vec<DirectiveExternalDependency>>, - pub source_type: Option<String>, +pub struct CreateChainRequest { + pub name: String, + pub description: Option<String>, + pub repository_url: Option<String>, + pub repositories: Option<Vec<AddChainRepositoryRequest>>, + pub loop_enabled: Option<bool>, + pub loop_max_iterations: Option<i32>, + pub loop_progress_check: Option<String>, + pub contracts: Option<Vec<CreateChainContractRequest>>, } -/// Request to initialize a directive-driven chain -#[derive(Debug, Clone, Deserialize, ToSchema)] +/// Request to add a repository to a chain (legacy - used by chain runner) +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct InitChainRequest { - /// High-level goal/description for the directive contract - pub goal: String, - /// Repository URL for chain contracts +pub struct AddChainRepositoryRequest { + pub name: String, pub repository_url: Option<String>, - /// Local path for chain contracts pub local_path: Option<String>, - /// Whether to enable phase guard (user approval between phases) + #[serde(default = "default_source_type")] + pub source_type: String, #[serde(default)] - pub phase_guard: bool, -} - -/// Response from initializing a directive-driven chain -#[derive(Debug, Clone, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct InitChainResponse { - pub chain_id: Uuid, - pub directive_contract_id: Uuid, - pub supervisor_task_id: Option<Uuid>, -} - -// ============================================================================= -// Contract Evaluations (LLM evaluation results for completed contracts) -// ============================================================================= - -/// Evaluation status for chain contracts -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum EvaluationStatus { - /// Not yet evaluated - Pending, - /// Currently being evaluated - Evaluating, - /// Evaluation passed - Passed, - /// Evaluation failed - Failed, - /// Contract is being reworked after failed evaluation - Rework, - /// Max retries exceeded, escalated to user - Escalated, - /// User approved despite partial failure - ApprovedWithIssues, -} - -impl std::fmt::Display for EvaluationStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Pending => write!(f, "pending"), - Self::Evaluating => write!(f, "evaluating"), - Self::Passed => write!(f, "passed"), - Self::Failed => write!(f, "failed"), - Self::Rework => write!(f, "rework"), - Self::Escalated => write!(f, "escalated"), - Self::ApprovedWithIssues => write!(f, "approved_with_issues"), - } - } -} - -impl std::str::FromStr for EvaluationStatus { - type Err = String; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - match s.to_lowercase().as_str() { - "pending" => Ok(Self::Pending), - "evaluating" => Ok(Self::Evaluating), - "passed" => Ok(Self::Passed), - "failed" => Ok(Self::Failed), - "rework" => Ok(Self::Rework), - "escalated" => Ok(Self::Escalated), - "approved_with_issues" => Ok(Self::ApprovedWithIssues), - _ => Err(format!("Unknown evaluation status: {}", s)), - } - } -} - -/// Contract evaluation - LLM evaluation result after contract completion -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractEvaluation { - pub id: Uuid, - pub contract_id: Uuid, - pub chain_id: Option<Uuid>, - pub chain_contract_id: Option<Uuid>, - /// Evaluation attempt number (1-based) - pub evaluation_number: i32, - /// Model used for evaluation - pub evaluator_model: Option<String>, - /// Whether the evaluation passed - pub passed: bool, - /// Overall score (0.0-1.0) - pub overall_score: Option<f64>, - /// Per-criterion results as JSON - #[sqlx(json)] - pub criteria_results: serde_json::Value, - /// Summary feedback from the evaluator - pub summary_feedback: String, - /// Instructions for rework if evaluation failed - pub rework_instructions: Option<String>, - /// Snapshot of directive at evaluation time - pub directive_snapshot: Option<serde_json::Value>, - /// Snapshot of deliverables at evaluation time - pub deliverables_snapshot: Option<serde_json::Value>, - pub started_at: DateTime<Utc>, - pub completed_at: Option<DateTime<Utc>>, - pub created_at: DateTime<Utc>, -} - -/// Per-criterion evaluation result -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct EvaluationCriterionResult { - pub criterion_id: String, - pub criterion_text: String, - pub passed: bool, - /// Score (0.0-1.0) - pub score: f64, - pub feedback: String, - /// Evidence supporting the evaluation - pub evidence: Vec<String>, -} - -/// Request to create a contract evaluation -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractEvaluationRequest { - pub contract_id: Uuid, - pub chain_id: Option<Uuid>, - pub chain_contract_id: Option<Uuid>, - pub evaluator_model: Option<String>, - pub passed: bool, - pub overall_score: Option<f64>, - pub criteria_results: Vec<EvaluationCriterionResult>, - pub summary_feedback: String, - pub rework_instructions: Option<String>, + pub is_primary: bool, } -/// Summary of contract evaluation for list views -#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractEvaluationSummary { - pub id: Uuid, - pub contract_id: Uuid, - pub evaluation_number: i32, - pub passed: bool, - pub overall_score: Option<f64>, - pub summary_feedback: String, - pub created_at: DateTime<Utc>, +fn default_source_type() -> String { + "remote".to_string() } -/// Response listing evaluations for a chain or contract -#[derive(Debug, Clone, Serialize, ToSchema)] +/// Request to create a contract within a chain (legacy - used by chain runner) +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ContractEvaluationsResponse { - pub evaluations: Vec<ContractEvaluationSummary>, - pub total: i64, +pub struct CreateChainContractRequest { + pub name: String, + pub description: Option<String>, + #[serde(default)] + pub contract_type: Option<String>, + pub initial_phase: Option<String>, + pub phases: Option<Vec<String>>, + pub depends_on: Option<Vec<String>>, + pub tasks: Option<Vec<CreateChainTaskRequest>>, + pub deliverables: Option<Vec<CreateChainDeliverableRequest>>, + pub editor_x: Option<f64>, + pub editor_y: Option<f64>, } -/// Traceability matrix entry - maps requirements to contracts -#[derive(Debug, Clone, Serialize, ToSchema)] +/// Task definition within a chain contract (legacy - used by chain runner) +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct TraceabilityEntry { - pub requirement_id: String, - pub requirement_title: String, - pub contract_definition_ids: Vec<Uuid>, - pub contract_definition_names: Vec<String>, - pub acceptance_criteria_ids: Vec<String>, +pub struct CreateChainTaskRequest { + pub name: String, + pub plan: String, } -/// Response for directive traceability -#[derive(Debug, Clone, Serialize, ToSchema)] +/// Deliverable definition within a chain contract (legacy - used by chain runner) +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct DirectiveTraceabilityResponse { - pub chain_id: Uuid, - pub entries: Vec<TraceabilityEntry>, - /// Requirements not mapped to any contract - pub uncovered_requirements: Vec<String>, +pub struct CreateChainDeliverableRequest { + pub id: String, + pub name: String, + pub priority: Option<String>, } // ============================================================================= diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 7be7bc8..eeda4a5 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -6,23 +6,22 @@ use sqlx::PgPool; use uuid::Uuid; use super::models::{ - AddChainRepositoryRequest, AddContractDefinitionRequest, AddContractToChainRequest, Chain, - ChainContract, ChainContractDefinition, ChainContractDetail, ChainDefinitionGraphNode, - ChainDefinitionGraphResponse, ChainDirective, ChainEditorContract, ChainEditorData, - ChainEditorDeliverable, ChainEditorEdge, ChainEditorNode, ChainEditorTask, ChainEvent, - ChainGraphEdge, ChainGraphNode, ChainGraphResponse, ChainRepository, ChainSummary, - ChainWithContracts, CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation, - ContractChatMessageRecord, ContractEvaluation, ContractEvaluationSummary, ContractEvent, - ContractRepository, ContractSummary, ContractTypeTemplateRecord, ConversationMessage, - ConversationSnapshot, CreateChainDirectiveRequest, CreateChainRequest, - CreateContractEvaluationRequest, CreateContractRequest, CreateFileRequest, CreateTaskRequest, - CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, DeliverableDefinition, - DirectiveTraceabilityResponse, EvaluationCriterionResult, File, FileSummary, FileVersion, - HistoryEvent, HistoryQueryFilters, InitChainRequest, InitChainResponse, MeshChatConversation, - MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, - SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, - TraceabilityEntry, UpdateChainRequest, UpdateContractDefinitionRequest, UpdateContractRequest, - UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, + // Core types + CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation, + ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary, + ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, + CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, + Daemon, DaemonTaskAssignment, DaemonWithCapacity, DeliverableDefinition, + File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, + MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, + PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, + TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateTemplateRequest, + // Directive types + AddStepRequest, ChainStep, CreateDirectiveRequest, Directive, DirectiveApproval, + DirectiveChain, DirectiveChainGraphEdge, DirectiveChainGraphNode, DirectiveChainGraphResponse, + DirectiveEvaluation, DirectiveEvent, DirectiveSummary, DirectiveVerifier, + DirectiveWithProgress, UpdateDirectiveRequest, UpdateStepRequest, }; /// Repository error types. @@ -4906,46 +4905,140 @@ pub async fn sync_supervisor_state( } // ============================================================================= -// Chain Operations (DAG of contracts for multi-contract orchestration) +// Directive Operations (top-level orchestration entity) // ============================================================================= +// TODO: Implement directive CRUD functions +// - create_directive_for_owner +// - get_directive_for_owner +// - list_directives_for_owner +// - update_directive_for_owner +// - archive_directive_for_owner +// - update_directive_status -/// Create a new chain for a specific owner. -pub async fn create_chain_for_owner( +// ============================================================================= +// Directive Chain Operations (generated execution plans) +// ============================================================================= +// TODO: Implement chain CRUD functions +// - create_directive_chain +// - get_current_chain +// - supersede_chain + +// ============================================================================= +// Chain Step Operations (nodes in the DAG) +// ============================================================================= +// TODO: Implement step CRUD functions +// - create_chain_step +// - update_chain_step +// - delete_chain_step +// - find_ready_steps +// - update_step_status +// - update_step_contract +// - update_step_confidence +// - increment_step_rework_count + +// ============================================================================= +// Directive Evaluation Operations +// ============================================================================= +// TODO: Implement evaluation functions +// - create_directive_evaluation +// - list_step_evaluations +// - list_directive_evaluations + +// ============================================================================= +// Directive Event Operations (audit stream) +// ============================================================================= +// TODO: Implement event functions +// - emit_directive_event +// - list_directive_events + +// ============================================================================= +// Directive Verifier Operations +// ============================================================================= +// TODO: Implement verifier CRUD functions +// - create_directive_verifier +// - list_directive_verifiers +// - update_directive_verifier + +// ============================================================================= +// Directive Approval Operations (human-in-the-loop) +// ============================================================================= +// TODO: Implement approval functions +// - create_approval_request +// - resolve_approval +// - list_pending_approvals + +// NOTE: Old chain functions removed. See git history for reference. +// Old functions included: create_chain_for_owner, get_chain_for_owner, +// list_chains_for_owner, update_chain_for_owner, delete_chain_for_owner, +// add_contract_to_chain, remove_contract_from_chain, list_chain_contracts, +// get_chain_with_contracts, list_chain_repositories, add_chain_repository, +// delete_chain_repository, set_chain_repository_primary, get_chain_graph, +// record_chain_event, list_chain_events, increment_chain_loop, complete_chain, +// get_ready_chain_contracts, is_chain_complete, get_chain_editor_data, +// create_chain_contract_definition, list_chain_contract_definitions, +// update_chain_contract_definition, delete_chain_contract_definition, +// get_chain_definition_graph, update_chain_status, progress_chain, +// create_chain_directive, get_chain_directive, update_chain_directive, +// delete_chain_directive, create_contract_evaluation, get_contract_evaluation, +// list_chain_evaluations, update_chain_contract_evaluation_status, +// mark_chain_contract_original_completion, get_chain_contract_by_contract_id, +// init_chain_for_owner. + +// ============================================================================= +// Directive Operations +// ============================================================================= + +/// Create a new directive for an owner. +pub async fn create_directive_for_owner( pool: &PgPool, owner_id: Uuid, - req: CreateChainRequest, -) -> Result<Chain, sqlx::Error> { - let loop_enabled = req.loop_enabled.unwrap_or(false); - let loop_max_iterations = req.loop_max_iterations.unwrap_or(10); - - sqlx::query_as::<_, Chain>( - r#" - INSERT INTO chains (owner_id, name, description, loop_enabled, loop_max_iterations, loop_progress_check) - VALUES ($1, $2, $3, $4, $5, $6) + req: CreateDirectiveRequest, +) -> Result<Directive, sqlx::Error> { + let title = req.title.unwrap_or_else(|| truncate_string(&req.goal, 100)); + let autonomy_level = req.autonomy_level.unwrap_or_else(|| "guardrails".to_string()); + let green_threshold = req.confidence_threshold_green.unwrap_or(0.85); + let yellow_threshold = req.confidence_threshold_yellow.unwrap_or(0.60); + let requirements = req.requirements.unwrap_or(serde_json::json!([])); + let acceptance_criteria = req.acceptance_criteria.unwrap_or(serde_json::json!([])); + + sqlx::query_as::<_, Directive>( + r#" + INSERT INTO directives ( + owner_id, title, goal, requirements, acceptance_criteria, + autonomy_level, confidence_threshold_green, confidence_threshold_yellow, + repository_url, local_path, base_branch, + max_total_cost_usd, max_wall_time_minutes + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING * "#, ) .bind(owner_id) - .bind(&req.name) - .bind(&req.description) - .bind(loop_enabled) - .bind(loop_max_iterations) - .bind(&req.loop_progress_check) + .bind(&title) + .bind(&req.goal) + .bind(&requirements) + .bind(&acceptance_criteria) + .bind(&autonomy_level) + .bind(green_threshold) + .bind(yellow_threshold) + .bind(&req.repository_url) + .bind(&req.local_path) + .bind(&req.base_branch) + .bind(req.max_total_cost_usd) + .bind(req.max_wall_time_minutes) .fetch_one(pool) .await } -/// Get a chain by ID, scoped to owner. -pub async fn get_chain_for_owner( +/// Get a directive by ID, scoped to owner. +pub async fn get_directive_for_owner( pool: &PgPool, id: Uuid, owner_id: Uuid, -) -> Result<Option<Chain>, sqlx::Error> { - sqlx::query_as::<_, Chain>( +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( r#" - SELECT * - FROM chains - WHERE id = $1 AND owner_id = $2 + SELECT * FROM directives WHERE id = $1 AND owner_id = $2 "#, ) .bind(id) @@ -4954,817 +5047,485 @@ pub async fn get_chain_for_owner( .await } -/// Get a chain by ID (no owner check - for internal use). -pub async fn get_chain(pool: &PgPool, id: Uuid) -> Result<Option<Chain>, sqlx::Error> { - sqlx::query_as::<_, Chain>( - r#" - SELECT * - FROM chains - WHERE id = $1 - "#, +/// Get a directive by ID (no owner check - for internal use). +pub async fn get_directive(pool: &PgPool, id: Uuid) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE id = $1"#, ) .bind(id) .fetch_optional(pool) .await } -/// List chains for a specific owner. -pub async fn list_chains_for_owner( +/// List directives for an owner. +pub async fn list_directives_for_owner( pool: &PgPool, owner_id: Uuid, -) -> Result<Vec<ChainSummary>, sqlx::Error> { - sqlx::query_as::<_, ChainSummary>( - r#" - SELECT - c.id, - c.name, - c.description, - c.status, - c.loop_enabled, - c.loop_current_iteration, - COUNT(DISTINCT cc.contract_id) as contract_count, - COUNT(DISTINCT CASE WHEN con.status = 'completed' THEN cc.contract_id END) as completed_count, - c.version, - c.created_at - FROM chains c - LEFT JOIN chain_contracts cc ON cc.chain_id = c.id - LEFT JOIN contracts con ON con.id = cc.contract_id - WHERE c.owner_id = $1 - GROUP BY c.id - ORDER BY c.created_at DESC - "#, - ) - .bind(owner_id) - .fetch_all(pool) - .await + status_filter: Option<&str>, +) -> Result<Vec<DirectiveSummary>, sqlx::Error> { + let query = if let Some(status) = status_filter { + sqlx::query_as::<_, DirectiveSummary>( + r#" + SELECT + d.id, d.title, d.goal, d.status, d.autonomy_level, + dc.current_confidence, + COALESCE(dc.completed_steps, 0) as completed_steps, + COALESCE(dc.total_steps, 0) as total_steps, + d.chain_generation_count, d.started_at, d.created_at + FROM directives d + LEFT JOIN directive_chains dc ON dc.id = d.current_chain_id + WHERE d.owner_id = $1 AND d.status = $2 + ORDER BY d.created_at DESC + "#, + ) + .bind(owner_id) + .bind(status) + } else { + sqlx::query_as::<_, DirectiveSummary>( + r#" + SELECT + d.id, d.title, d.goal, d.status, d.autonomy_level, + dc.current_confidence, + COALESCE(dc.completed_steps, 0) as completed_steps, + COALESCE(dc.total_steps, 0) as total_steps, + d.chain_generation_count, d.started_at, d.created_at + FROM directives d + LEFT JOIN directive_chains dc ON dc.id = d.current_chain_id + WHERE d.owner_id = $1 + ORDER BY d.created_at DESC + "#, + ) + .bind(owner_id) + }; + query.fetch_all(pool).await } -/// Update a chain. -pub async fn update_chain_for_owner( +/// Update a directive with optimistic locking. +pub async fn update_directive_for_owner( pool: &PgPool, id: Uuid, owner_id: Uuid, - req: UpdateChainRequest, -) -> Result<Chain, RepositoryError> { - // First get current version if optimistic locking requested - if let Some(expected_version) = req.version { - let current: Option<(i32,)> = sqlx::query_as( - "SELECT version FROM chains WHERE id = $1 AND owner_id = $2", - ) - .bind(id) - .bind(owner_id) - .fetch_optional(pool) - .await?; + req: UpdateDirectiveRequest, +) -> Result<Directive, RepositoryError> { + // First get current version + let current = sqlx::query_scalar::<_, i32>( + "SELECT version FROM directives WHERE id = $1 AND owner_id = $2" + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| RepositoryError::Database(sqlx::Error::RowNotFound))?; - if let Some((actual_version,)) = current { - if actual_version != expected_version { - return Err(RepositoryError::VersionConflict { - expected: expected_version, - actual: actual_version, - }); - } - } + if current != req.version { + return Err(RepositoryError::VersionConflict { + expected: req.version, + actual: current, + }); } - let result = sqlx::query_as::<_, Chain>( - r#" - UPDATE chains - SET - name = COALESCE($3, name), - description = COALESCE($4, description), - status = COALESCE($5, status), - loop_enabled = COALESCE($6, loop_enabled), - loop_max_iterations = COALESCE($7, loop_max_iterations), - loop_progress_check = COALESCE($8, loop_progress_check), + let directive = sqlx::query_as::<_, Directive>( + r#" + UPDATE directives SET + title = COALESCE($3, title), + goal = COALESCE($4, goal), + requirements = COALESCE($5, requirements), + acceptance_criteria = COALESCE($6, acceptance_criteria), + constraints = COALESCE($7, constraints), + external_dependencies = COALESCE($8, external_dependencies), + autonomy_level = COALESCE($9, autonomy_level), + confidence_threshold_green = COALESCE($10, confidence_threshold_green), + confidence_threshold_yellow = COALESCE($11, confidence_threshold_yellow), + max_total_cost_usd = COALESCE($12, max_total_cost_usd), + max_wall_time_minutes = COALESCE($13, max_wall_time_minutes), + max_rework_cycles = COALESCE($14, max_rework_cycles), + max_chain_regenerations = COALESCE($15, max_chain_regenerations), version = version + 1, updated_at = NOW() - WHERE id = $1 AND owner_id = $2 + WHERE id = $1 AND owner_id = $2 AND version = $16 RETURNING * "#, ) .bind(id) .bind(owner_id) - .bind(&req.name) - .bind(&req.description) - .bind(&req.status) - .bind(req.loop_enabled) - .bind(req.loop_max_iterations) - .bind(&req.loop_progress_check) + .bind(&req.title) + .bind(&req.goal) + .bind(&req.requirements) + .bind(&req.acceptance_criteria) + .bind(&req.constraints) + .bind(&req.external_dependencies) + .bind(&req.autonomy_level) + .bind(req.confidence_threshold_green) + .bind(req.confidence_threshold_yellow) + .bind(req.max_total_cost_usd) + .bind(req.max_wall_time_minutes) + .bind(req.max_rework_cycles) + .bind(req.max_chain_regenerations) + .bind(req.version) .fetch_one(pool) .await?; - Ok(result) + Ok(directive) } -/// Delete (archive) a chain. -pub async fn delete_chain_for_owner( +/// Update directive status. +pub async fn update_directive_status( pool: &PgPool, id: Uuid, - owner_id: Uuid, -) -> Result<bool, sqlx::Error> { - let result = sqlx::query( - r#" - UPDATE chains - SET status = 'archived', updated_at = NOW() - WHERE id = $1 AND owner_id = $2 - "#, - ) - .bind(id) - .bind(owner_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Add a contract to a chain. -pub async fn add_contract_to_chain( - pool: &PgPool, - chain_id: Uuid, - contract_id: Uuid, - depends_on: Vec<Uuid>, - order_index: i32, - editor_x: Option<f64>, - editor_y: Option<f64>, -) -> Result<ChainContract, sqlx::Error> { - // Also update the contract's chain_id - sqlx::query("UPDATE contracts SET chain_id = $1 WHERE id = $2") - .bind(chain_id) - .bind(contract_id) - .execute(pool) - .await?; - - sqlx::query_as::<_, ChainContract>( + status: &str, +) -> Result<Directive, sqlx::Error> { + sqlx::query_as::<_, Directive>( r#" - INSERT INTO chain_contracts (chain_id, contract_id, depends_on, order_index, editor_x, editor_y) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (chain_id, contract_id) DO UPDATE SET - depends_on = EXCLUDED.depends_on, - order_index = EXCLUDED.order_index, - editor_x = EXCLUDED.editor_x, - editor_y = EXCLUDED.editor_y + UPDATE directives SET + status = $2, + started_at = CASE WHEN $2 = 'active' AND started_at IS NULL THEN NOW() ELSE started_at END, + completed_at = CASE WHEN $2 IN ('completed', 'failed', 'archived') THEN NOW() ELSE completed_at END, + updated_at = NOW() + WHERE id = $1 RETURNING * "#, ) - .bind(chain_id) - .bind(contract_id) - .bind(&depends_on) - .bind(order_index) - .bind(editor_x) - .bind(editor_y) + .bind(id) + .bind(status) .fetch_one(pool) .await } -/// Remove a contract from a chain. -pub async fn remove_contract_from_chain( +/// Archive a directive (soft delete). +pub async fn archive_directive_for_owner( pool: &PgPool, - chain_id: Uuid, - contract_id: Uuid, + id: Uuid, + owner_id: Uuid, ) -> Result<bool, sqlx::Error> { - // Clear the contract's chain_id - sqlx::query("UPDATE contracts SET chain_id = NULL WHERE id = $1 AND chain_id = $2") - .bind(contract_id) - .bind(chain_id) - .execute(pool) - .await?; - let result = sqlx::query( r#" - DELETE FROM chain_contracts - WHERE chain_id = $1 AND contract_id = $2 + UPDATE directives SET status = 'archived', updated_at = NOW() + WHERE id = $1 AND owner_id = $2 "#, ) - .bind(chain_id) - .bind(contract_id) + .bind(id) + .bind(owner_id) .execute(pool) .await?; - Ok(result.rows_affected() > 0) } -/// List contracts in a chain with their details. -pub async fn list_chain_contracts( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Vec<ChainContractDetail>, sqlx::Error> { - sqlx::query_as::<_, ChainContractDetail>( - r#" - SELECT - cc.id as chain_contract_id, - cc.contract_id, - c.name as contract_name, - c.status as contract_status, - c.phase as contract_phase, - cc.depends_on, - cc.order_index, - cc.editor_x, - cc.editor_y, - cc.evaluation_status, - cc.evaluation_retry_count, - cc.max_evaluation_retries, - cc.created_at - FROM chain_contracts cc - JOIN contracts c ON c.id = cc.contract_id - WHERE cc.chain_id = $1 - ORDER BY cc.order_index ASC - "#, - ) - .bind(chain_id) - .fetch_all(pool) - .await -} - -/// Get chain with all contracts for detail view. -pub async fn get_chain_with_contracts( +/// Get directive with full progress info. +pub async fn get_directive_with_progress( pool: &PgPool, - chain_id: Uuid, + id: Uuid, owner_id: Uuid, -) -> Result<Option<ChainWithContracts>, sqlx::Error> { - let chain = get_chain_for_owner(pool, chain_id, owner_id).await?; - - match chain { - Some(chain) => { - let contracts = list_chain_contracts(pool, chain_id).await?; - let repositories = list_chain_repositories(pool, chain_id).await?; - Ok(Some(ChainWithContracts { - chain, - contracts, - repositories, - })) - } - None => Ok(None), - } -} +) -> Result<Option<DirectiveWithProgress>, sqlx::Error> { + let directive = match get_directive_for_owner(pool, id, owner_id).await? { + Some(d) => d, + None => return Ok(None), + }; -// ============================================================================= -// Chain Repository Operations -// ============================================================================= + let chain = if let Some(chain_id) = directive.current_chain_id { + get_directive_chain(pool, chain_id).await? + } else { + None + }; -/// List all repositories for a chain. -pub async fn list_chain_repositories( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Vec<ChainRepository>, sqlx::Error> { - sqlx::query_as::<_, ChainRepository>( - r#" - SELECT * - FROM chain_repositories - WHERE chain_id = $1 - ORDER BY is_primary DESC, created_at ASC - "#, - ) - .bind(chain_id) - .fetch_all(pool) - .await -} + let steps = if let Some(ref c) = chain { + list_chain_steps(pool, c.id).await? + } else { + vec![] + }; -/// Get a chain repository by ID. -pub async fn get_chain_repository( - pool: &PgPool, - chain_id: Uuid, - repository_id: Uuid, -) -> Result<Option<ChainRepository>, sqlx::Error> { - sqlx::query_as::<_, ChainRepository>( - r#" - SELECT * - FROM chain_repositories - WHERE id = $1 AND chain_id = $2 - "#, - ) - .bind(repository_id) - .bind(chain_id) - .fetch_optional(pool) - .await + let recent_events = list_directive_events(pool, id, Some(20)).await?; + let pending_approvals = list_pending_approvals(pool, id).await?; + + Ok(Some(DirectiveWithProgress { + directive, + chain, + steps, + recent_events, + pending_approvals, + })) } -/// Add a repository to a chain. -pub async fn add_chain_repository( +// ============================================================================= +// Directive Chain Operations +// ============================================================================= + +/// Create a new chain generation for a directive. +pub async fn create_directive_chain( pool: &PgPool, - chain_id: Uuid, - req: &AddChainRepositoryRequest, -) -> Result<ChainRepository, sqlx::Error> { - // If is_primary, clear other primaries first - if req.is_primary { - sqlx::query( - r#" - UPDATE chain_repositories - SET is_primary = false, updated_at = NOW() - WHERE chain_id = $1 AND is_primary = true - "#, - ) - .bind(chain_id) - .execute(pool) - .await?; - } + directive_id: Uuid, + name: &str, + description: Option<&str>, + rationale: Option<&str>, + planning_model: Option<&str>, +) -> Result<DirectiveChain, sqlx::Error> { + // Get next generation number + let generation = sqlx::query_scalar::<_, i32>( + "SELECT COALESCE(MAX(generation), 0) + 1 FROM directive_chains WHERE directive_id = $1" + ) + .bind(directive_id) + .fetch_one(pool) + .await?; - sqlx::query_as::<_, ChainRepository>( + let chain = sqlx::query_as::<_, DirectiveChain>( r#" - INSERT INTO chain_repositories (chain_id, name, repository_url, local_path, source_type, status, is_primary) - VALUES ($1, $2, $3, $4, $5, 'ready', $6) + INSERT INTO directive_chains (directive_id, generation, name, description, rationale, planning_model) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING * "#, ) - .bind(chain_id) - .bind(&req.name) - .bind(&req.repository_url) - .bind(&req.local_path) - .bind(&req.source_type) - .bind(req.is_primary) + .bind(directive_id) + .bind(generation) + .bind(name) + .bind(description) + .bind(rationale) + .bind(planning_model) .fetch_one(pool) - .await -} - -/// Delete a repository from a chain. -pub async fn delete_chain_repository( - pool: &PgPool, - chain_id: Uuid, - repository_id: Uuid, -) -> Result<bool, sqlx::Error> { - let result = sqlx::query( - r#" - DELETE FROM chain_repositories - WHERE id = $1 AND chain_id = $2 - "#, - ) - .bind(repository_id) - .bind(chain_id) - .execute(pool) .await?; - Ok(result.rows_affected() > 0) -} - -/// Set a repository as primary for a chain. -pub async fn set_chain_repository_primary( - pool: &PgPool, - chain_id: Uuid, - repository_id: Uuid, -) -> Result<ChainRepository, sqlx::Error> { - // Clear existing primary + // Update directive to point to new chain and increment generation count sqlx::query( r#" - UPDATE chain_repositories - SET is_primary = false, updated_at = NOW() - WHERE chain_id = $1 AND is_primary = true + UPDATE directives SET + current_chain_id = $2, + chain_generation_count = chain_generation_count + 1, + updated_at = NOW() + WHERE id = $1 "#, ) - .bind(chain_id) + .bind(directive_id) + .bind(chain.id) .execute(pool) .await?; - // Set new primary - sqlx::query_as::<_, ChainRepository>( - r#" - UPDATE chain_repositories - SET is_primary = true, updated_at = NOW() - WHERE id = $1 AND chain_id = $2 - RETURNING * - "#, - ) - .bind(repository_id) - .bind(chain_id) - .fetch_one(pool) - .await + Ok(chain) } -/// Get the primary repository for a chain. -pub async fn get_chain_primary_repository( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Option<ChainRepository>, sqlx::Error> { - sqlx::query_as::<_, ChainRepository>( - r#" - SELECT * - FROM chain_repositories - WHERE chain_id = $1 AND is_primary = true - "#, +/// Get a directive chain by ID. +pub async fn get_directive_chain(pool: &PgPool, id: Uuid) -> Result<Option<DirectiveChain>, sqlx::Error> { + sqlx::query_as::<_, DirectiveChain>( + "SELECT * FROM directive_chains WHERE id = $1" ) - .bind(chain_id) + .bind(id) .fetch_optional(pool) .await } -/// Get chain graph structure for visualization. -pub async fn get_chain_graph( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Option<ChainGraphResponse>, sqlx::Error> { - let chain = get_chain(pool, chain_id).await?; - - match chain { - Some(chain) => { - let contracts = list_chain_contracts(pool, chain_id).await?; - - let nodes: Vec<ChainGraphNode> = contracts - .iter() - .map(|c| ChainGraphNode { - id: c.chain_contract_id, - contract_id: c.contract_id, - name: c.contract_name.clone(), - status: c.contract_status.clone(), - phase: c.contract_phase.clone(), - x: c.editor_x.unwrap_or(0.0), - y: c.editor_y.unwrap_or(0.0), - }) - .collect(); - - let mut edges: Vec<ChainGraphEdge> = Vec::new(); - for contract in &contracts { - for dep_id in &contract.depends_on { - // Find the chain_contract_id for this dependency - if let Some(dep) = contracts.iter().find(|c| c.contract_id == *dep_id) { - edges.push(ChainGraphEdge { - from: dep.chain_contract_id, - to: contract.chain_contract_id, - }); - } - } - } - - Ok(Some(ChainGraphResponse { - chain_id: chain.id, - chain_name: chain.name, - chain_status: chain.status, - nodes, - edges, - })) - } - None => Ok(None), - } -} - -/// Record a chain event. -pub async fn record_chain_event( - pool: &PgPool, - chain_id: Uuid, - event_type: &str, - contract_id: Option<Uuid>, - event_data: Option<serde_json::Value>, -) -> Result<ChainEvent, sqlx::Error> { - sqlx::query_as::<_, ChainEvent>( +/// Get the current chain for a directive. +pub async fn get_current_chain(pool: &PgPool, directive_id: Uuid) -> Result<Option<DirectiveChain>, sqlx::Error> { + sqlx::query_as::<_, DirectiveChain>( r#" - INSERT INTO chain_events (chain_id, event_type, contract_id, event_data) - VALUES ($1, $2, $3, $4) - RETURNING * + SELECT dc.* FROM directive_chains dc + JOIN directives d ON d.current_chain_id = dc.id + WHERE d.id = $1 "#, ) - .bind(chain_id) - .bind(event_type) - .bind(contract_id) - .bind(event_data) - .fetch_one(pool) + .bind(directive_id) + .fetch_optional(pool) .await } -/// List chain events. -pub async fn list_chain_events( +/// Update chain status. +pub async fn update_chain_status( pool: &PgPool, chain_id: Uuid, -) -> Result<Vec<ChainEvent>, sqlx::Error> { - sqlx::query_as::<_, ChainEvent>( - r#" - SELECT * - FROM chain_events - WHERE chain_id = $1 - ORDER BY created_at DESC - "#, - ) - .bind(chain_id) - .fetch_all(pool) - .await -} - -/// Increment chain loop iteration. -pub async fn increment_chain_loop(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> { - sqlx::query_as::<_, Chain>( + status: &str, +) -> Result<DirectiveChain, sqlx::Error> { + sqlx::query_as::<_, DirectiveChain>( r#" - UPDATE chains - SET loop_current_iteration = COALESCE(loop_current_iteration, 0) + 1, + UPDATE directive_chains SET + status = $2, + started_at = CASE WHEN $2 = 'active' AND started_at IS NULL THEN NOW() ELSE started_at END, + completed_at = CASE WHEN $2 IN ('completed', 'failed', 'superseded') THEN NOW() ELSE completed_at END, updated_at = NOW() WHERE id = $1 RETURNING * "#, ) .bind(chain_id) + .bind(status) .fetch_one(pool) .await } -/// Mark a chain as completed. -pub async fn complete_chain(pool: &PgPool, chain_id: Uuid) -> Result<Chain, sqlx::Error> { - sqlx::query_as::<_, Chain>( +/// Supersede a chain (mark as superseded and update directive). +pub async fn supersede_chain(pool: &PgPool, chain_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( r#" - UPDATE chains - SET status = 'completed', - updated_at = NOW() + UPDATE directive_chains SET status = 'superseded', completed_at = NOW(), updated_at = NOW() WHERE id = $1 - RETURNING * - "#, - ) - .bind(chain_id) - .fetch_one(pool) - .await -} - -/// Get contracts in a chain that have no pending dependencies (ready to start). -/// Returns contracts where all depends_on contracts are completed. -pub async fn get_ready_chain_contracts( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Vec<ChainContractDetail>, sqlx::Error> { - sqlx::query_as::<_, ChainContractDetail>( - r#" - SELECT - cc.id as chain_contract_id, - cc.contract_id, - c.name as contract_name, - c.status as contract_status, - c.phase as contract_phase, - cc.depends_on, - cc.order_index, - cc.editor_x, - cc.editor_y - FROM chain_contracts cc - JOIN contracts c ON c.id = cc.contract_id - WHERE cc.chain_id = $1 - AND c.status = 'active' - AND ( - -- No dependencies - cc.depends_on IS NULL - OR array_length(cc.depends_on, 1) IS NULL - OR array_length(cc.depends_on, 1) = 0 - -- Or all dependencies completed - OR NOT EXISTS ( - SELECT 1 - FROM unnest(cc.depends_on) AS dep_id - JOIN contracts dep ON dep.id = dep_id - WHERE dep.status != 'completed' - ) - ) - ORDER BY cc.order_index ASC "#, ) .bind(chain_id) - .fetch_all(pool) - .await -} - -/// Check if all contracts in a chain are completed. -pub async fn is_chain_complete(pool: &PgPool, chain_id: Uuid) -> Result<bool, sqlx::Error> { - let result: (i64,) = sqlx::query_as( - r#" - SELECT COUNT(*) - FROM chain_contracts cc - JOIN contracts c ON c.id = cc.contract_id - WHERE cc.chain_id = $1 - AND c.status != 'completed' - "#, - ) - .bind(chain_id) - .fetch_one(pool) + .execute(pool) .await?; - - Ok(result.0 == 0) -} - -/// Get chain editor data for the GUI editor. -pub async fn get_chain_editor_data( - pool: &PgPool, - chain_id: Uuid, - owner_id: Uuid, -) -> Result<Option<ChainEditorData>, sqlx::Error> { - let chain = get_chain_for_owner(pool, chain_id, owner_id).await?; - - match chain { - Some(chain) => { - let contracts = list_chain_contracts(pool, chain_id).await?; - let repositories = list_chain_repositories(pool, chain_id).await?; - - // Build nodes - let nodes: Vec<ChainEditorNode> = contracts - .iter() - .map(|c| ChainEditorNode { - id: c.contract_id.to_string(), - x: c.editor_x.unwrap_or(0.0), - y: c.editor_y.unwrap_or(0.0), - contract: ChainEditorContract { - name: c.contract_name.clone(), - description: None, // Would need to join with full contract data - contract_type: "simple".to_string(), - phases: vec!["plan".to_string(), "execute".to_string()], - tasks: vec![], - deliverables: vec![], - }, - }) - .collect(); - - // Build edges - let edges: Vec<ChainEditorEdge> = contracts - .iter() - .flat_map(|c| { - c.depends_on.iter().map(move |dep_id| ChainEditorEdge { - from: dep_id.to_string(), - to: c.contract_id.to_string(), - }) - }) - .collect(); - - Ok(Some(ChainEditorData { - id: Some(chain.id), - name: chain.name, - description: chain.description, - repositories, - loop_enabled: chain.loop_enabled, - loop_max_iterations: chain.loop_max_iterations, - loop_progress_check: chain.loop_progress_check, - nodes, - edges, - })) - } - None => Ok(None), - } + Ok(()) } // ============================================================================= -// Chain Contract Definition Operations +// Chain Step Operations // ============================================================================= -/// Create a new contract definition in a chain. -pub async fn create_chain_contract_definition( +/// Create a new step in a chain. +pub async fn create_chain_step( pool: &PgPool, chain_id: Uuid, - req: AddContractDefinitionRequest, -) -> Result<ChainContractDefinition, sqlx::Error> { - // Get the next order index - let max_order: Option<i32> = sqlx::query_scalar( - "SELECT MAX(order_index) FROM chain_contract_definitions WHERE chain_id = $1", + req: AddStepRequest, +) -> Result<ChainStep, sqlx::Error> { + let step_type = req.step_type.unwrap_or_else(|| "execute".to_string()); + let contract_type = req.contract_type.unwrap_or_else(|| "simple".to_string()); + let phases = req.phases.unwrap_or_default(); + let depends_on = req.depends_on.unwrap_or_default(); + let requirement_ids = req.requirement_ids.unwrap_or_default(); + let acceptance_criteria_ids = req.acceptance_criteria_ids.unwrap_or_default(); + let verifier_config = req.verifier_config.unwrap_or(serde_json::json!({})); + + // Get next order index + let order_index = sqlx::query_scalar::<_, i32>( + "SELECT COALESCE(MAX(order_index), 0) + 1 FROM chain_steps WHERE chain_id = $1" ) .bind(chain_id) .fetch_one(pool) .await?; - let order_index = max_order.unwrap_or(-1) + 1; - - // Convert tasks, deliverables, and validation to JSON - let tasks_json = req.tasks.as_ref().map(|t| serde_json::to_value(t).unwrap()); - let deliverables_json = req - .deliverables - .as_ref() - .map(|d| serde_json::to_value(d).unwrap()); - let validation_json = req - .validation - .as_ref() - .map(|v| serde_json::to_value(v).unwrap()); - let depends_on_names: Vec<String> = req.depends_on.unwrap_or_default(); - - sqlx::query_as::<_, ChainContractDefinition>( - r#" - INSERT INTO chain_contract_definitions - (chain_id, name, description, contract_type, initial_phase, depends_on_names, tasks, deliverables, validation, editor_x, editor_y, order_index) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + let step = sqlx::query_as::<_, ChainStep>( + r#" + INSERT INTO chain_steps ( + chain_id, name, description, step_type, contract_type, + initial_phase, task_plan, phases, depends_on, parallel_group, + requirement_ids, acceptance_criteria_ids, verifier_config, + editor_x, editor_y, order_index + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * "#, ) .bind(chain_id) .bind(&req.name) .bind(&req.description) - .bind(&req.contract_type) + .bind(&step_type) + .bind(&contract_type) .bind(&req.initial_phase) - .bind(&depends_on_names) - .bind(&tasks_json) - .bind(&deliverables_json) - .bind(&validation_json) - .bind(req.editor_x) - .bind(req.editor_y) + .bind(&req.task_plan) + .bind(&phases) + .bind(&depends_on) + .bind(&req.parallel_group) + .bind(&requirement_ids) + .bind(&acceptance_criteria_ids) + .bind(&verifier_config) + .bind(req.editor_x.unwrap_or(0.0)) + .bind(req.editor_y.unwrap_or(0.0)) .bind(order_index) .fetch_one(pool) - .await -} + .await?; -/// List all contract definitions in a chain. -pub async fn list_chain_contract_definitions( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Vec<ChainContractDefinition>, sqlx::Error> { - sqlx::query_as::<_, ChainContractDefinition>( - r#" - SELECT * FROM chain_contract_definitions - WHERE chain_id = $1 - ORDER BY order_index ASC - "#, + // Update chain total_steps count + sqlx::query( + "UPDATE directive_chains SET total_steps = total_steps + 1, updated_at = NOW() WHERE id = $1" ) .bind(chain_id) - .fetch_all(pool) - .await + .execute(pool) + .await?; + + Ok(step) } -/// Get a specific contract definition. -pub async fn get_chain_contract_definition( - pool: &PgPool, - definition_id: Uuid, -) -> Result<Option<ChainContractDefinition>, sqlx::Error> { - sqlx::query_as::<_, ChainContractDefinition>( - "SELECT * FROM chain_contract_definitions WHERE id = $1", +/// Get a chain step by ID. +pub async fn get_chain_step(pool: &PgPool, id: Uuid) -> Result<Option<ChainStep>, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( + "SELECT * FROM chain_steps WHERE id = $1" ) - .bind(definition_id) + .bind(id) .fetch_optional(pool) .await } -/// Update a contract definition. -pub async fn update_chain_contract_definition( - pool: &PgPool, - definition_id: Uuid, - req: UpdateContractDefinitionRequest, -) -> Result<ChainContractDefinition, sqlx::Error> { - let tasks_json = req.tasks.as_ref().map(|t| serde_json::to_value(t).unwrap()); - let deliverables_json = req - .deliverables - .as_ref() - .map(|d| serde_json::to_value(d).unwrap()); - let validation_json = req - .validation - .as_ref() - .map(|v| serde_json::to_value(v).unwrap()); +/// List all steps in a chain. +pub async fn list_chain_steps(pool: &PgPool, chain_id: Uuid) -> Result<Vec<ChainStep>, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( + "SELECT * FROM chain_steps WHERE chain_id = $1 ORDER BY order_index" + ) + .bind(chain_id) + .fetch_all(pool) + .await +} - sqlx::query_as::<_, ChainContractDefinition>( +/// Update a chain step. +pub async fn update_chain_step( + pool: &PgPool, + step_id: Uuid, + req: UpdateStepRequest, +) -> Result<ChainStep, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( r#" - UPDATE chain_contract_definitions SET + UPDATE chain_steps SET name = COALESCE($2, name), description = COALESCE($3, description), - contract_type = COALESCE($4, contract_type), - initial_phase = COALESCE($5, initial_phase), - depends_on_names = COALESCE($6, depends_on_names), - tasks = COALESCE($7, tasks), - deliverables = COALESCE($8, deliverables), - validation = COALESCE($9, validation), - editor_x = COALESCE($10, editor_x), - editor_y = COALESCE($11, editor_y) + task_plan = COALESCE($4, task_plan), + depends_on = COALESCE($5, depends_on), + requirement_ids = COALESCE($6, requirement_ids), + acceptance_criteria_ids = COALESCE($7, acceptance_criteria_ids), + verifier_config = COALESCE($8, verifier_config), + editor_x = COALESCE($9, editor_x), + editor_y = COALESCE($10, editor_y) WHERE id = $1 RETURNING * "#, ) - .bind(definition_id) + .bind(step_id) .bind(&req.name) .bind(&req.description) - .bind(&req.contract_type) - .bind(&req.initial_phase) + .bind(&req.task_plan) .bind(&req.depends_on) - .bind(&tasks_json) - .bind(&deliverables_json) - .bind(&validation_json) + .bind(&req.requirement_ids) + .bind(&req.acceptance_criteria_ids) + .bind(&req.verifier_config) .bind(req.editor_x) .bind(req.editor_y) .fetch_one(pool) .await } -/// Delete a contract definition. -pub async fn delete_chain_contract_definition( - pool: &PgPool, - definition_id: Uuid, -) -> Result<bool, sqlx::Error> { - let result = sqlx::query("DELETE FROM chain_contract_definitions WHERE id = $1") - .bind(definition_id) +/// Delete a chain step. +pub async fn delete_chain_step(pool: &PgPool, step_id: Uuid) -> Result<bool, sqlx::Error> { + // Get chain_id first for updating count + let chain_id = sqlx::query_scalar::<_, Uuid>( + "SELECT chain_id FROM chain_steps WHERE id = $1" + ) + .bind(step_id) + .fetch_optional(pool) + .await?; + + let result = sqlx::query("DELETE FROM chain_steps WHERE id = $1") + .bind(step_id) .execute(pool) .await?; + + // Update chain total_steps count + if let Some(cid) = chain_id { + sqlx::query( + "UPDATE directive_chains SET total_steps = total_steps - 1, updated_at = NOW() WHERE id = $1" + ) + .bind(cid) + .execute(pool) + .await?; + } + Ok(result.rows_affected() > 0) } -/// Get definitions that are ready to be instantiated (all dependencies are satisfied). -/// A definition is ready if all definitions it depends on have been instantiated as contracts -/// and those contracts have completed. -pub async fn get_ready_definitions( - pool: &PgPool, - chain_id: Uuid, -) -> Result<Vec<ChainContractDefinition>, sqlx::Error> { - sqlx::query_as::<_, ChainContractDefinition>( - r#" - SELECT d.* - FROM chain_contract_definitions d - WHERE d.chain_id = $1 - -- Not already instantiated - AND NOT EXISTS ( - SELECT 1 FROM chain_contracts cc - WHERE cc.definition_id = d.id - ) - -- All dependencies satisfied (either no deps, or all deps have completed contracts) - AND ( - cardinality(d.depends_on_names) = 0 - OR NOT EXISTS ( - SELECT 1 FROM unnest(d.depends_on_names) AS dep_name - WHERE NOT EXISTS ( - SELECT 1 FROM chain_contract_definitions dep_def - JOIN chain_contracts cc ON cc.definition_id = dep_def.id - JOIN contracts c ON c.id = cc.contract_id - WHERE dep_def.chain_id = d.chain_id - AND dep_def.name = dep_name - AND c.status = 'completed' - ) - ) - ) - ORDER BY d.order_index ASC +/// Find steps that are ready to execute (all dependencies met, status=pending). +pub async fn find_ready_steps(pool: &PgPool, chain_id: Uuid) -> Result<Vec<ChainStep>, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( + r#" + SELECT s.* FROM chain_steps s + WHERE s.chain_id = $1 + AND s.status = 'pending' + AND NOT EXISTS ( + SELECT 1 FROM chain_steps dep + WHERE dep.id = ANY(s.depends_on) + AND dep.status NOT IN ('passed', 'skipped') + ) + ORDER BY s.order_index "#, ) .bind(chain_id) @@ -5772,909 +5533,500 @@ pub async fn get_ready_definitions( .await } -/// Get the definition graph for visualization. -pub async fn get_chain_definition_graph( +/// Update step status. +pub async fn update_step_status( pool: &PgPool, - chain_id: Uuid, -) -> Result<Option<ChainDefinitionGraphResponse>, sqlx::Error> { - let chain = sqlx::query_as::<_, Chain>("SELECT * FROM chains WHERE id = $1") - .bind(chain_id) - .fetch_optional(pool) - .await?; - - let Some(chain) = chain else { - return Ok(None); - }; - - let definitions = list_chain_contract_definitions(pool, chain_id).await?; - - // Get instantiated contracts for each definition - let chain_contracts = list_chain_contracts(pool, chain_id).await?; - let instantiated: std::collections::HashMap<Uuid, &ChainContractDetail> = chain_contracts - .iter() - .filter_map(|cc| { - // Find definition_id from cc - we need to query this - // For now, match by name - definitions - .iter() - .find(|d| d.name == cc.contract_name) - .map(|d| (d.id, cc)) - }) - .collect(); - - let nodes: Vec<ChainDefinitionGraphNode> = definitions - .iter() - .map(|d| { - let cc = instantiated.get(&d.id); - ChainDefinitionGraphNode { - id: d.id, - name: d.name.clone(), - contract_type: d.contract_type.clone(), - x: d.editor_x.unwrap_or(0.0), - y: d.editor_y.unwrap_or(0.0), - is_instantiated: cc.is_some(), - contract_id: cc.map(|c| c.contract_id), - contract_status: cc.map(|c| c.contract_status.clone()), - } - }) - .collect(); - - // Build edges from depends_on_names - let name_to_id: std::collections::HashMap<&str, Uuid> = - definitions.iter().map(|d| (d.name.as_str(), d.id)).collect(); - - let edges: Vec<ChainGraphEdge> = definitions - .iter() - .flat_map(|d| { - let target_id = d.id; - let name_to_id = &name_to_id; - d.depends_on_names.iter().filter_map(move |dep_name| { - name_to_id - .get(dep_name.as_str()) - .map(|&from_id| ChainGraphEdge { from: from_id, to: target_id }) - }) - }) - .collect(); - - Ok(Some(ChainDefinitionGraphResponse { - chain_id: chain.id, - chain_name: chain.name, - chain_status: chain.status, - nodes, - edges, - })) -} - -/// Update chain status. -pub async fn update_chain_status( - pool: &PgPool, - chain_id: Uuid, + step_id: Uuid, status: &str, -) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE chains SET status = $2, updated_at = NOW() WHERE id = $1") - .bind(chain_id) - .bind(status) - .execute(pool) - .await?; - Ok(()) -} - -// ============================================================================= -// Chain Progression -// ============================================================================= - -/// Result of chain progression check -#[derive(Debug)] -pub struct ChainProgressionResult { - /// Contracts created from ready definitions - pub contracts_created: Vec<Uuid>, - /// Whether all definitions are instantiated and completed (chain is done) - pub chain_completed: bool, -} - -/// Progress a chain by creating contracts from ready definitions. -/// -/// This is called when a contract in the chain completes. It: -/// 1. Finds definitions whose dependencies are all satisfied (completed) -/// 2. Creates contracts from those definitions -/// 3. Links them to the chain -/// 4. Checks if chain is complete (all definitions instantiated and completed) -pub async fn progress_chain( - pool: &PgPool, - chain_id: Uuid, - owner_id: Uuid, -) -> Result<ChainProgressionResult, sqlx::Error> { - let mut contracts_created = Vec::new(); - - // Get all definitions for this chain - let definitions = list_chain_contract_definitions(pool, chain_id).await?; - if definitions.is_empty() { - return Ok(ChainProgressionResult { - contracts_created: vec![], - chain_completed: true, - }); - } - - // Get existing chain contracts to know what's already instantiated - let chain_contracts = list_chain_contracts(pool, chain_id).await?; - - // Build a map of definition name -> instantiated contract status - let instantiated: std::collections::HashMap<String, Option<String>> = chain_contracts - .iter() - .map(|cc| (cc.contract_name.clone(), Some(cc.contract_status.clone()))) - .collect(); - - // Find definitions that are ready to be instantiated: - // - Not yet instantiated - // - All dependencies are instantiated AND completed - for def in &definitions { - // Skip if already instantiated - if instantiated.contains_key(&def.name) { - continue; - } - - // Check if all dependencies are completed - let deps_satisfied = def.depends_on_names.iter().all(|dep_name| { - instantiated - .get(dep_name) - .map(|status| status.as_deref() == Some("completed")) - .unwrap_or(false) - }); - - // Root definitions (no dependencies) are always ready - let is_root = def.depends_on_names.is_empty(); - - if is_root || deps_satisfied { - // Create contract from definition - match create_contract_from_definition(pool, chain_id, owner_id, def).await { - Ok(contract_id) => { - contracts_created.push(contract_id); - tracing::info!( - chain_id = %chain_id, - definition_name = %def.name, - contract_id = %contract_id, - "Created contract from chain definition" - ); - } - Err(e) => { - tracing::error!( - chain_id = %chain_id, - definition_name = %def.name, - error = %e, - "Failed to create contract from chain definition" - ); - } - } - } - } - - // Check if chain is complete (all definitions instantiated and completed) - let updated_contracts = list_chain_contracts(pool, chain_id).await?; - let all_instantiated = definitions.len() == updated_contracts.len(); - let all_completed = updated_contracts - .iter() - .all(|cc| cc.contract_status == "completed"); - let chain_completed = all_instantiated && all_completed; - - if chain_completed { - update_chain_status(pool, chain_id, "completed").await?; - tracing::info!(chain_id = %chain_id, "Chain completed - all contracts done"); - } - - Ok(ChainProgressionResult { - contracts_created, - chain_completed, - }) -} - -/// Task definition parsed from JSON (matches chain YAML format) -#[derive(Debug, Clone, serde::Deserialize)] -struct ChainTaskDef { - name: String, - plan: String, -} - -/// Validation config parsed from definition JSON -#[derive(Debug, Clone, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct ValidationConfig { - #[serde(default)] - check_deliverables: bool, - #[serde(default)] - run_tests: bool, - check_content: Option<String>, - #[serde(default = "default_on_failure_str")] - on_failure: String, - #[serde(default = "default_max_retries_val")] - max_retries: i32, -} - -fn default_on_failure_str() -> String { - "block".to_string() -} - -fn default_max_retries_val() -> i32 { - 3 -} - -/// Generate a validation plan for a checkpoint contract. -fn generate_checkpoint_plan( - def: &ChainContractDefinition, - upstream_contracts: &[&ChainContractDetail], - validation: &ValidationConfig, -) -> String { - let upstream_names: Vec<&str> = upstream_contracts.iter().map(|c| c.contract_name.as_str()).collect(); - - let mut plan = format!( - r#"# Checkpoint Validation: {} - -You are validating the outputs of upstream contracts before allowing downstream work to proceed. - -## Upstream Contracts to Validate -{} - -"#, - def.name, - upstream_names.iter().map(|n| format!("- {}", n)).collect::<Vec<_>>().join("\n") - ); - - // Add deliverables check section - if validation.check_deliverables { - plan.push_str(r#"## Deliverables Check -Verify that all required deliverables from upstream contracts exist and are properly completed. - -Use the makima CLI to check contract status: -```bash -makima contract status <contract_id> -``` - -For each upstream contract, verify: -1. Contract status is "completed" -2. All required deliverables are marked as complete -3. Deliverable content exists and is not empty - -"#); - } - - // Add tests check section - if validation.run_tests { - plan.push_str(r#"## Tests Check -Run the test suite to verify the codebase is in a good state. - -```bash -# Run tests appropriate for the project type -npm test # for Node.js projects -cargo test # for Rust projects -pytest # for Python projects -go test ./... # for Go projects -``` - -Verify: -1. All tests pass -2. No new test failures introduced -3. Test coverage is acceptable - -"#); - } - - // Add custom content check section - if let Some(content_check) = &validation.check_content { - plan.push_str(&format!(r#"## Custom Validation Criteria -{} - -"#, content_check)); - } - - // Add validation result section - plan.push_str(&format!(r#"## Reporting Results - -After completing all validation checks, you must report the result: - -**If ALL checks pass:** -Mark this checkpoint contract as completed using: -```bash -makima supervisor complete -``` - -**If ANY check fails (on_failure: "{}"):** -"#, validation.on_failure)); - - match validation.on_failure.as_str() { - "block" => plan.push_str(r#" -- Document the failure reason clearly -- Do NOT mark the contract as complete -- The chain will be blocked until issues are resolved manually -"#), - "retry" => plan.push_str(&format!(r#" -- Document the failure reason -- Request retry of the failed upstream contract (max {} retries) -- Use: `makima supervisor ask "Upstream validation failed. Retry?" --choices "Yes,No"` -"#, validation.max_retries)), - "warn" => plan.push_str(r#" -- Document the warning/issue found -- Mark the contract as complete anyway (downstream will proceed) -- Log the warning for visibility -"#), - _ => plan.push_str(r#" -- Document the failure reason -- Do NOT mark the contract as complete -"#), - } - - plan.push_str(r#" -## Begin Validation - -Start by checking the status of each upstream contract, then proceed with the validation criteria above. -"#); - - plan -} - -/// Create a contract from a chain definition. -async fn create_contract_from_definition( - pool: &PgPool, - chain_id: Uuid, - owner_id: Uuid, - def: &ChainContractDefinition, -) -> Result<Uuid, sqlx::Error> { - // Get the existing contracts to find dependency info - let existing_contracts = list_chain_contracts(pool, chain_id).await?; - let name_to_contract: std::collections::HashMap<&str, &ChainContractDetail> = existing_contracts - .iter() - .map(|cc| (cc.contract_name.as_str(), cc)) - .collect(); - - // Resolve dependency names to contract details - let upstream_contracts: Vec<&ChainContractDetail> = def - .depends_on_names - .iter() - .filter_map(|name| name_to_contract.get(name.as_str()).copied()) - .collect(); - - // Create the contract request with basic fields - let req = CreateContractRequest { - name: def.name.clone(), - description: def.description.clone(), - contract_type: Some(def.contract_type.clone()), - initial_phase: def.initial_phase.clone(), - template_id: None, - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - }; - - // Create the contract - let contract = create_contract_for_owner(pool, owner_id, req).await?; - - // For checkpoint contracts, generate a validation plan - if def.contract_type == "checkpoint" { - // Parse validation config - let validation: ValidationConfig = def - .validation - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or(ValidationConfig { - check_deliverables: true, - run_tests: false, - check_content: None, - on_failure: default_on_failure_str(), - max_retries: default_max_retries_val(), - }); - - // Generate validation plan - let validation_plan = generate_checkpoint_plan(def, &upstream_contracts, &validation); - - // Create a supervisor task with the validation plan - let task_req = CreateTaskRequest { - contract_id: Some(contract.id), - name: format!("Validate: {}", def.name), - description: Some("Checkpoint validation task".to_string()), - plan: validation_plan, - parent_task_id: None, - is_supervisor: true, // Checkpoint uses supervisor task for validation - priority: 0, - repository_url: None, - base_branch: None, - target_branch: None, - merge_mode: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, - }; - - if let Err(e) = create_task_for_owner(pool, owner_id, task_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create validation task for checkpoint contract" - ); - } +) -> Result<ChainStep, sqlx::Error> { + let step = sqlx::query_as::<_, ChainStep>( + r#" + UPDATE chain_steps SET + status = $2, + started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END, + completed_at = CASE WHEN $2 IN ('passed', 'failed', 'skipped') THEN NOW() ELSE completed_at END + WHERE id = $1 + RETURNING * + "#, + ) + .bind(step_id) + .bind(status) + .fetch_one(pool) + .await?; - // Set initial validation status + // Update chain completed_steps and failed_steps counts + if status == "passed" || status == "skipped" { sqlx::query( - "UPDATE chain_contracts SET validation_status = 'pending' WHERE chain_id = $1 AND contract_id = $2", + "UPDATE directive_chains SET completed_steps = completed_steps + 1, updated_at = NOW() WHERE id = $1" ) - .bind(chain_id) - .bind(contract.id) + .bind(step.chain_id) .execute(pool) .await?; - } else { - // Parse and create tasks from definition for regular contracts - if let Some(tasks_json) = &def.tasks { - if let Ok(tasks) = serde_json::from_value::<Vec<ChainTaskDef>>(tasks_json.clone()) { - for task_def in tasks { - let task_req = CreateTaskRequest { - contract_id: Some(contract.id), - name: task_def.name, - description: None, - plan: task_def.plan, - parent_task_id: None, - is_supervisor: false, - priority: 0, - repository_url: None, - base_branch: None, - target_branch: None, - merge_mode: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, - }; - if let Err(e) = create_task_for_owner(pool, owner_id, task_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create task from chain definition" - ); - } - } - } - } - } - - // Resolve dependency names to contract IDs - let depends_on: Vec<Uuid> = upstream_contracts.iter().map(|c| c.contract_id).collect(); - - // Link contract to chain - add_contract_to_chain( - pool, - chain_id, - contract.id, - depends_on, - def.order_index, - def.editor_x, - def.editor_y, - ) - .await?; - - // Update chain_contracts with definition_id link - sqlx::query( - "UPDATE chain_contracts SET definition_id = $1 WHERE chain_id = $2 AND contract_id = $3", - ) - .bind(def.id) - .bind(chain_id) - .bind(contract.id) - .execute(pool) - .await?; - - // Copy repositories from chain to contract - let chain_repos = list_chain_repositories(pool, chain_id).await.unwrap_or_default(); - for repo in chain_repos { - if let Some(url) = &repo.repository_url { - // Remote repository - if let Err(e) = add_remote_repository(pool, contract.id, &repo.name, url, repo.is_primary).await { - tracing::warn!( - contract_id = %contract.id, - repo_name = %repo.name, - error = %e, - "Failed to copy repository from chain to contract" - ); - } - } else if let Some(path) = &repo.local_path { - // Local repository - if let Err(e) = add_local_repository(pool, contract.id, &repo.name, path, repo.is_primary).await { - tracing::warn!( - contract_id = %contract.id, - repo_name = %repo.name, - error = %e, - "Failed to copy local repository from chain to contract" - ); - } - } - } - - // Activate the contract so it can start - sqlx::query("UPDATE contracts SET status = 'active' WHERE id = $1") - .bind(contract.id) + } else if status == "failed" { + sqlx::query( + "UPDATE directive_chains SET failed_steps = failed_steps + 1, updated_at = NOW() WHERE id = $1" + ) + .bind(step.chain_id) .execute(pool) .await?; + } - tracing::info!( - contract_id = %contract.id, - contract_name = %def.name, - chain_id = %chain_id, - "Contract created and activated from chain definition" - ); - - Ok(contract.id) + Ok(step) } -// ============================================================================= -// Chain Directives -// ============================================================================= - -/// Create a directive for a chain. -pub async fn create_chain_directive( +/// Link a step to a contract. +pub async fn update_step_contract( pool: &PgPool, - chain_id: Uuid, - req: CreateChainDirectiveRequest, -) -> Result<ChainDirective, sqlx::Error> { - let requirements = serde_json::to_value(&req.requirements.unwrap_or_default()) - .unwrap_or(serde_json::json!([])); - let acceptance_criteria = serde_json::to_value(&req.acceptance_criteria.unwrap_or_default()) - .unwrap_or(serde_json::json!([])); - let constraints = - serde_json::to_value(&req.constraints.unwrap_or_default()).unwrap_or(serde_json::json!([])); - let external_dependencies = - serde_json::to_value(&req.external_dependencies.unwrap_or_default()) - .unwrap_or(serde_json::json!([])); - let source_type = req.source_type.unwrap_or_else(|| "llm_generated".to_string()); - - sqlx::query_as::<_, ChainDirective>( - r#" - INSERT INTO chain_directives (chain_id, requirements, acceptance_criteria, constraints, external_dependencies, source_type) - VALUES ($1, $2, $3, $4, $5, $6) + step_id: Uuid, + contract_id: Uuid, + supervisor_task_id: Option<Uuid>, +) -> Result<ChainStep, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( + r#" + UPDATE chain_steps SET contract_id = $2, supervisor_task_id = $3 + WHERE id = $1 RETURNING * "#, ) - .bind(chain_id) - .bind(&requirements) - .bind(&acceptance_criteria) - .bind(&constraints) - .bind(&external_dependencies) - .bind(&source_type) + .bind(step_id) + .bind(contract_id) + .bind(supervisor_task_id) .fetch_one(pool) .await } -/// Get the directive for a chain. -pub async fn get_chain_directive( +/// Update step confidence score and level. +pub async fn update_step_confidence( pool: &PgPool, - chain_id: Uuid, -) -> Result<Option<ChainDirective>, sqlx::Error> { - sqlx::query_as::<_, ChainDirective>( + step_id: Uuid, + score: f64, + level: &str, + evaluation_id: Uuid, +) -> Result<ChainStep, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( r#" - SELECT * - FROM chain_directives - WHERE chain_id = $1 + UPDATE chain_steps SET + confidence_score = $2, + confidence_level = $3, + last_evaluation_id = $4, + evaluation_count = evaluation_count + 1 + WHERE id = $1 + RETURNING * "#, ) - .bind(chain_id) - .fetch_optional(pool) + .bind(step_id) + .bind(score) + .bind(level) + .bind(evaluation_id) + .fetch_one(pool) .await } -/// Update a chain directive. -pub async fn update_chain_directive( - pool: &PgPool, - chain_id: Uuid, - req: CreateChainDirectiveRequest, -) -> Result<ChainDirective, sqlx::Error> { - let requirements = req - .requirements - .map(|r| serde_json::to_value(&r).unwrap_or(serde_json::json!([]))); - let acceptance_criteria = req - .acceptance_criteria - .map(|ac| serde_json::to_value(&ac).unwrap_or(serde_json::json!([]))); - let constraints = req - .constraints - .map(|c| serde_json::to_value(&c).unwrap_or(serde_json::json!([]))); - let external_dependencies = req - .external_dependencies - .map(|ed| serde_json::to_value(&ed).unwrap_or(serde_json::json!([]))); - - sqlx::query_as::<_, ChainDirective>( - r#" - UPDATE chain_directives SET - requirements = COALESCE($2, requirements), - acceptance_criteria = COALESCE($3, acceptance_criteria), - constraints = COALESCE($4, constraints), - external_dependencies = COALESCE($5, external_dependencies), - source_type = COALESCE($6, source_type), - version = version + 1, - updated_at = NOW() - WHERE chain_id = $1 +/// Increment step rework count. +pub async fn increment_step_rework_count(pool: &PgPool, step_id: Uuid) -> Result<ChainStep, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( + r#" + UPDATE chain_steps SET rework_count = rework_count + 1, status = 'rework' + WHERE id = $1 RETURNING * "#, ) - .bind(chain_id) - .bind(&requirements) - .bind(&acceptance_criteria) - .bind(&constraints) - .bind(&external_dependencies) - .bind(&req.source_type) + .bind(step_id) .fetch_one(pool) .await } -/// Delete a chain directive. -pub async fn delete_chain_directive(pool: &PgPool, chain_id: Uuid) -> Result<bool, sqlx::Error> { - let result = sqlx::query("DELETE FROM chain_directives WHERE chain_id = $1") - .bind(chain_id) - .execute(pool) - .await?; - Ok(result.rows_affected() > 0) -} - -/// Get directive traceability (requirement -> contract mapping). -pub async fn get_directive_traceability( +/// Get chain graph for visualization. +pub async fn get_chain_graph( pool: &PgPool, chain_id: Uuid, -) -> Result<DirectiveTraceabilityResponse, sqlx::Error> { - // Get the directive - let directive = get_chain_directive(pool, chain_id).await?; - - // Get all contract definitions with their requirement mappings - let definitions = list_chain_contract_definitions(pool, chain_id).await?; - - // Parse requirements from directive - let requirements: Vec<super::models::DirectiveRequirement> = directive - .as_ref() - .and_then(|d| serde_json::from_value(d.requirements.clone()).ok()) - .unwrap_or_default(); - - // Build traceability entries - let mut entries: Vec<TraceabilityEntry> = Vec::new(); - let mut covered_requirements: std::collections::HashSet<String> = - std::collections::HashSet::new(); - - for req in &requirements { - let mut contract_def_ids: Vec<Uuid> = Vec::new(); - let mut contract_def_names: Vec<String> = Vec::new(); - - for def in &definitions { - if def.requirement_ids.contains(&req.id) { - contract_def_ids.push(def.id); - contract_def_names.push(def.name.clone()); - covered_requirements.insert(req.id.clone()); - } +) -> Result<DirectiveChainGraphResponse, sqlx::Error> { + let chain = get_directive_chain(pool, chain_id).await? + .ok_or_else(|| sqlx::Error::RowNotFound)?; + + let steps = list_chain_steps(pool, chain_id).await?; + + let nodes: Vec<DirectiveChainGraphNode> = steps.iter().map(|s| { + DirectiveChainGraphNode { + id: s.id, + name: s.name.clone(), + step_type: s.step_type.clone(), + status: s.status.clone(), + confidence_score: s.confidence_score, + confidence_level: s.confidence_level.clone(), + contract_id: s.contract_id, + editor_x: s.editor_x, + editor_y: s.editor_y, + } + }).collect(); + + let mut edges = Vec::new(); + for step in &steps { + for dep_id in &step.depends_on { + edges.push(DirectiveChainGraphEdge { + source: *dep_id, + target: step.id, + }); } - - // Get acceptance criteria for this requirement - let acceptance_criteria: Vec<super::models::DirectiveAcceptanceCriterion> = directive - .as_ref() - .and_then(|d| serde_json::from_value(d.acceptance_criteria.clone()).ok()) - .unwrap_or_default(); - - let ac_ids: Vec<String> = acceptance_criteria - .iter() - .filter(|ac| ac.requirement_ids.contains(&req.id)) - .map(|ac| ac.id.clone()) - .collect(); - - entries.push(TraceabilityEntry { - requirement_id: req.id.clone(), - requirement_title: req.title.clone(), - contract_definition_ids: contract_def_ids, - contract_definition_names: contract_def_names, - acceptance_criteria_ids: ac_ids, - }); } - // Find uncovered requirements - let uncovered: Vec<String> = requirements - .iter() - .filter(|r| !covered_requirements.contains(&r.id)) - .map(|r| r.id.clone()) - .collect(); - - Ok(DirectiveTraceabilityResponse { + Ok(DirectiveChainGraphResponse { chain_id, - entries, - uncovered_requirements: uncovered, + directive_id: chain.directive_id, + nodes, + edges, }) } // ============================================================================= -// Contract Evaluations +// Directive Evaluation Operations // ============================================================================= -/// Create a contract evaluation record. -pub async fn create_contract_evaluation( +/// Create a directive evaluation. +pub async fn create_directive_evaluation( pool: &PgPool, - req: CreateContractEvaluationRequest, -) -> Result<ContractEvaluation, sqlx::Error> { - let criteria_results = serde_json::to_value(&req.criteria_results).unwrap_or(serde_json::json!([])); + directive_id: Uuid, + chain_id: Option<Uuid>, + step_id: Option<Uuid>, + contract_id: Option<Uuid>, + evaluation_type: &str, + evaluator: Option<&str>, + passed: bool, + overall_score: Option<f64>, + confidence_level: Option<&str>, + programmatic_results: serde_json::Value, + llm_results: serde_json::Value, + criteria_results: serde_json::Value, + summary_feedback: &str, + rework_instructions: Option<&str>, +) -> Result<DirectiveEvaluation, sqlx::Error> { + // Get next evaluation number for this step/directive + let evaluation_number = if let Some(sid) = step_id { + sqlx::query_scalar::<_, i32>( + "SELECT COALESCE(MAX(evaluation_number), 0) + 1 FROM directive_evaluations WHERE step_id = $1" + ) + .bind(sid) + .fetch_one(pool) + .await? + } else { + sqlx::query_scalar::<_, i32>( + "SELECT COALESCE(MAX(evaluation_number), 0) + 1 FROM directive_evaluations WHERE directive_id = $1 AND step_id IS NULL" + ) + .bind(directive_id) + .fetch_one(pool) + .await? + }; - sqlx::query_as::<_, ContractEvaluation>( + sqlx::query_as::<_, DirectiveEvaluation>( r#" - INSERT INTO contract_evaluations ( - contract_id, chain_id, chain_contract_id, - evaluator_model, passed, overall_score, - criteria_results, summary_feedback, rework_instructions, + INSERT INTO directive_evaluations ( + directive_id, chain_id, step_id, contract_id, + evaluation_type, evaluation_number, evaluator, + passed, overall_score, confidence_level, + programmatic_results, llm_results, criteria_results, + summary_feedback, rework_instructions, completed_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW()) RETURNING * "#, ) - .bind(req.contract_id) - .bind(req.chain_id) - .bind(req.chain_contract_id) - .bind(&req.evaluator_model) - .bind(req.passed) - .bind(req.overall_score) + .bind(directive_id) + .bind(chain_id) + .bind(step_id) + .bind(contract_id) + .bind(evaluation_type) + .bind(evaluation_number) + .bind(evaluator) + .bind(passed) + .bind(overall_score) + .bind(confidence_level) + .bind(&programmatic_results) + .bind(&llm_results) .bind(&criteria_results) - .bind(&req.summary_feedback) - .bind(&req.rework_instructions) + .bind(summary_feedback) + .bind(rework_instructions) .fetch_one(pool) .await } -/// Get a contract evaluation by ID. -pub async fn get_contract_evaluation( +/// List evaluations for a step. +pub async fn list_step_evaluations( pool: &PgPool, - id: Uuid, -) -> Result<Option<ContractEvaluation>, sqlx::Error> { - sqlx::query_as::<_, ContractEvaluation>( - r#" - SELECT * - FROM contract_evaluations - WHERE id = $1 - "#, + step_id: Uuid, +) -> Result<Vec<DirectiveEvaluation>, sqlx::Error> { + sqlx::query_as::<_, DirectiveEvaluation>( + "SELECT * FROM directive_evaluations WHERE step_id = $1 ORDER BY evaluation_number DESC" ) - .bind(id) - .fetch_optional(pool) + .bind(step_id) + .fetch_all(pool) .await } -/// List evaluations for a contract. -pub async fn list_contract_evaluations( +/// List evaluations for a directive. +pub async fn list_directive_evaluations( pool: &PgPool, - contract_id: Uuid, -) -> Result<Vec<ContractEvaluationSummary>, sqlx::Error> { - sqlx::query_as::<_, ContractEvaluationSummary>( - r#" - SELECT id, contract_id, evaluation_number, passed, overall_score, summary_feedback, created_at - FROM contract_evaluations - WHERE contract_id = $1 - ORDER BY evaluation_number DESC - "#, + directive_id: Uuid, + limit: Option<i64>, +) -> Result<Vec<DirectiveEvaluation>, sqlx::Error> { + let limit = limit.unwrap_or(100); + sqlx::query_as::<_, DirectiveEvaluation>( + "SELECT * FROM directive_evaluations WHERE directive_id = $1 ORDER BY created_at DESC LIMIT $2" ) - .bind(contract_id) + .bind(directive_id) + .bind(limit) .fetch_all(pool) .await } -/// List evaluations for a chain. -pub async fn list_chain_evaluations( +// ============================================================================= +// Directive Event Operations +// ============================================================================= + +/// Emit a directive event. +pub async fn emit_directive_event( pool: &PgPool, - chain_id: Uuid, -) -> Result<Vec<ContractEvaluationSummary>, sqlx::Error> { - sqlx::query_as::<_, ContractEvaluationSummary>( + directive_id: Uuid, + chain_id: Option<Uuid>, + step_id: Option<Uuid>, + event_type: &str, + severity: &str, + event_data: Option<serde_json::Value>, + actor_type: &str, + actor_id: Option<Uuid>, +) -> Result<DirectiveEvent, sqlx::Error> { + sqlx::query_as::<_, DirectiveEvent>( r#" - SELECT id, contract_id, evaluation_number, passed, overall_score, summary_feedback, created_at - FROM contract_evaluations - WHERE chain_id = $1 - ORDER BY created_at DESC + INSERT INTO directive_events ( + directive_id, chain_id, step_id, event_type, severity, event_data, actor_type, actor_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * "#, ) + .bind(directive_id) .bind(chain_id) + .bind(step_id) + .bind(event_type) + .bind(severity) + .bind(event_data) + .bind(actor_type) + .bind(actor_id) + .fetch_one(pool) + .await +} + +/// List directive events. +pub async fn list_directive_events( + pool: &PgPool, + directive_id: Uuid, + limit: Option<i64>, +) -> Result<Vec<DirectiveEvent>, sqlx::Error> { + let limit = limit.unwrap_or(100); + sqlx::query_as::<_, DirectiveEvent>( + "SELECT * FROM directive_events WHERE directive_id = $1 ORDER BY created_at DESC LIMIT $2" + ) + .bind(directive_id) + .bind(limit) + .fetch_all(pool) + .await +} + +// ============================================================================= +// Directive Verifier Operations +// ============================================================================= + +/// Create a directive verifier. +pub async fn create_directive_verifier( + pool: &PgPool, + directive_id: Uuid, + name: &str, + verifier_type: &str, + command: Option<&str>, + working_directory: Option<&str>, + auto_detect: bool, + detect_files: Vec<String>, + weight: f64, + required: bool, +) -> Result<DirectiveVerifier, sqlx::Error> { + sqlx::query_as::<_, DirectiveVerifier>( + r#" + INSERT INTO directive_verifiers ( + directive_id, name, verifier_type, command, working_directory, + auto_detect, detect_files, weight, required + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + "#, + ) + .bind(directive_id) + .bind(name) + .bind(verifier_type) + .bind(command) + .bind(working_directory) + .bind(auto_detect) + .bind(&detect_files) + .bind(weight) + .bind(required) + .fetch_one(pool) + .await +} + +/// List verifiers for a directive. +pub async fn list_directive_verifiers( + pool: &PgPool, + directive_id: Uuid, +) -> Result<Vec<DirectiveVerifier>, sqlx::Error> { + sqlx::query_as::<_, DirectiveVerifier>( + "SELECT * FROM directive_verifiers WHERE directive_id = $1 ORDER BY name" + ) + .bind(directive_id) .fetch_all(pool) .await } -/// Get the latest evaluation for a chain contract. -pub async fn get_latest_chain_contract_evaluation( +/// Update a directive verifier. +pub async fn update_directive_verifier( pool: &PgPool, - chain_contract_id: Uuid, -) -> Result<Option<ContractEvaluation>, sqlx::Error> { - sqlx::query_as::<_, ContractEvaluation>( + verifier_id: Uuid, + enabled: Option<bool>, + command: Option<&str>, + weight: Option<f64>, + required: Option<bool>, +) -> Result<DirectiveVerifier, sqlx::Error> { + sqlx::query_as::<_, DirectiveVerifier>( r#" - SELECT * - FROM contract_evaluations - WHERE chain_contract_id = $1 - ORDER BY evaluation_number DESC - LIMIT 1 + UPDATE directive_verifiers SET + enabled = COALESCE($2, enabled), + command = COALESCE($3, command), + weight = COALESCE($4, weight), + required = COALESCE($5, required), + updated_at = NOW() + WHERE id = $1 + RETURNING * "#, ) - .bind(chain_contract_id) - .fetch_optional(pool) + .bind(verifier_id) + .bind(enabled) + .bind(command) + .bind(weight) + .bind(required) + .fetch_one(pool) .await } -/// Get the next evaluation number for a chain contract. -pub async fn get_next_evaluation_number( +/// Update verifier last run result. +pub async fn update_verifier_result( pool: &PgPool, - chain_contract_id: Uuid, -) -> Result<i32, sqlx::Error> { - let result: Option<(i32,)> = sqlx::query_as( + verifier_id: Uuid, + result: serde_json::Value, +) -> Result<DirectiveVerifier, sqlx::Error> { + sqlx::query_as::<_, DirectiveVerifier>( r#" - SELECT COALESCE(MAX(evaluation_number), 0) + 1 as next_number - FROM contract_evaluations - WHERE chain_contract_id = $1 + UPDATE directive_verifiers SET last_run_at = NOW(), last_result = $2, updated_at = NOW() + WHERE id = $1 + RETURNING * "#, ) - .bind(chain_contract_id) - .fetch_optional(pool) - .await?; + .bind(verifier_id) + .bind(result) + .fetch_one(pool) + .await +} - Ok(result.map(|(n,)| n).unwrap_or(1)) +// ============================================================================= +// Directive Approval Operations +// ============================================================================= + +/// Create an approval request. +pub async fn create_approval_request( + pool: &PgPool, + directive_id: Uuid, + step_id: Option<Uuid>, + approval_type: &str, + description: &str, + context: Option<serde_json::Value>, + urgency: &str, + expires_at: Option<chrono::DateTime<Utc>>, +) -> Result<DirectiveApproval, sqlx::Error> { + sqlx::query_as::<_, DirectiveApproval>( + r#" + INSERT INTO directive_approvals ( + directive_id, step_id, approval_type, description, context, urgency, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + "#, + ) + .bind(directive_id) + .bind(step_id) + .bind(approval_type) + .bind(description) + .bind(context) + .bind(urgency) + .bind(expires_at) + .fetch_one(pool) + .await } -/// Update chain contract evaluation status. -pub async fn update_chain_contract_evaluation_status( +/// Resolve an approval request. +pub async fn resolve_approval( pool: &PgPool, - chain_contract_id: Uuid, + approval_id: Uuid, status: &str, - evaluation_id: Option<Uuid>, - rework_feedback: Option<&str>, -) -> Result<ChainContract, sqlx::Error> { - sqlx::query_as::<_, ChainContract>( - r#" - UPDATE chain_contracts SET - evaluation_status = $2, - last_evaluation_id = COALESCE($3, last_evaluation_id), - rework_feedback = COALESCE($4, rework_feedback), - evaluation_retry_count = CASE - WHEN $2 = 'rework' THEN evaluation_retry_count + 1 - ELSE evaluation_retry_count - END, - rework_started_at = CASE - WHEN $2 = 'rework' THEN NOW() - ELSE rework_started_at - END + response: Option<&str>, + responded_by: Uuid, +) -> Result<DirectiveApproval, sqlx::Error> { + sqlx::query_as::<_, DirectiveApproval>( + r#" + UPDATE directive_approvals SET + status = $2, + response = $3, + responded_by = $4, + responded_at = NOW() WHERE id = $1 RETURNING * "#, ) - .bind(chain_contract_id) + .bind(approval_id) .bind(status) - .bind(evaluation_id) - .bind(rework_feedback) + .bind(response) + .bind(responded_by) .fetch_one(pool) .await } -/// Mark a chain contract's original completion time (before rework). -pub async fn mark_chain_contract_original_completion( +/// List pending approvals for a directive. +pub async fn list_pending_approvals( pool: &PgPool, - chain_contract_id: Uuid, -) -> Result<(), sqlx::Error> { - sqlx::query( + directive_id: Uuid, +) -> Result<Vec<DirectiveApproval>, sqlx::Error> { + sqlx::query_as::<_, DirectiveApproval>( r#" - UPDATE chain_contracts SET - original_completion_at = COALESCE(original_completion_at, NOW()) - WHERE id = $1 + SELECT * FROM directive_approvals + WHERE directive_id = $1 AND status = 'pending' + ORDER BY + CASE urgency + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'normal' THEN 3 + ELSE 4 + END, + created_at "#, ) - .bind(chain_contract_id) - .execute(pool) - .await?; - Ok(()) + .bind(directive_id) + .fetch_all(pool) + .await } -/// Get chain contract by contract ID. -pub async fn get_chain_contract_by_contract_id( +/// Get step by contract ID. +pub async fn get_step_by_contract_id( pool: &PgPool, contract_id: Uuid, -) -> Result<Option<ChainContract>, sqlx::Error> { - sqlx::query_as::<_, ChainContract>( - r#" - SELECT * - FROM chain_contracts - WHERE contract_id = $1 - "#, +) -> Result<Option<ChainStep>, sqlx::Error> { + sqlx::query_as::<_, ChainStep>( + "SELECT * FROM chain_steps WHERE contract_id = $1" ) .bind(contract_id) .fetch_optional(pool) @@ -6682,103 +6034,9 @@ pub async fn get_chain_contract_by_contract_id( } // ============================================================================= -// Init Chain (Directive-Driven Chain Creation) +// Helper Functions // ============================================================================= -/// Initialize a directive-driven chain. -/// Creates a directive contract and an empty chain linked to it. -pub async fn init_chain_for_owner( - pool: &PgPool, - owner_id: Uuid, - req: InitChainRequest, -) -> Result<InitChainResponse, sqlx::Error> { - // Create the directive contract - // Note: "directive" contract type uses the "specification" phases by default - let contract_req = CreateContractRequest { - name: format!("Directive: {}", truncate_string(&req.goal, 50)), - description: Some(req.goal.clone()), - contract_type: Some("specification".to_string()), // Directive uses spec workflow - template_id: None, - initial_phase: Some("research".to_string()), - phase_guard: Some(req.phase_guard), - autonomous_loop: Some(false), - local_only: Some(false), - auto_merge_local: Some(false), - }; - - let contract = create_contract_for_owner(pool, owner_id, contract_req).await?; - - // Mark it as a chain directive - sqlx::query("UPDATE contracts SET is_chain_directive = true WHERE id = $1") - .bind(contract.id) - .execute(pool) - .await?; - - // Build repositories list from request - let repositories = match (req.repository_url.as_ref(), req.local_path.as_ref()) { - (Some(url), _) => Some(vec![AddChainRepositoryRequest { - name: "Primary".to_string(), - repository_url: Some(url.clone()), - local_path: None, - source_type: "remote".to_string(), - is_primary: true, - }]), - (None, Some(path)) => Some(vec![AddChainRepositoryRequest { - name: "Primary".to_string(), - repository_url: None, - local_path: Some(path.clone()), - source_type: "local".to_string(), - is_primary: true, - }]), - (None, None) => None, - }; - - // Create the chain with directive contract reference - let chain_req = CreateChainRequest { - name: truncate_string(&req.goal, 100), - description: Some(req.goal), - repositories, - loop_enabled: Some(false), - loop_max_iterations: None, - loop_progress_check: None, - contracts: None, - }; - - let chain = create_chain_for_owner(pool, owner_id, chain_req).await?; - - // Link directive contract to chain - sqlx::query( - r#" - UPDATE chains SET directive_contract_id = $2 WHERE id = $1; - UPDATE contracts SET spawned_chain_id = $1 WHERE id = $2; - "#, - ) - .bind(chain.id) - .bind(contract.id) - .execute(pool) - .await?; - - // Create empty directive document - create_chain_directive( - pool, - chain.id, - CreateChainDirectiveRequest { - requirements: Some(vec![]), - acceptance_criteria: Some(vec![]), - constraints: Some(vec![]), - external_dependencies: Some(vec![]), - source_type: Some("llm_generated".to_string()), - }, - ) - .await?; - - Ok(InitChainResponse { - chain_id: chain.id, - directive_contract_id: contract.id, - supervisor_task_id: contract.supervisor_task_id, - }) -} - /// Helper to truncate string to max length fn truncate_string(s: &str, max_len: usize) -> String { if s.len() <= max_len { diff --git a/makima/src/lib.rs b/makima/src/lib.rs index 8d3db58..3bc460b 100644 --- a/makima/src/lib.rs +++ b/makima/src/lib.rs @@ -3,5 +3,6 @@ pub mod daemon; pub mod db; pub mod listen; pub mod llm; +pub mod orchestration; pub mod server; pub mod tts; diff --git a/makima/src/llm/contract_evaluator.rs b/makima/src/llm/contract_evaluator.rs index fcc4826..e63bbfa 100644 --- a/makima/src/llm/contract_evaluator.rs +++ b/makima/src/llm/contract_evaluator.rs @@ -1,25 +1,19 @@ //! Contract Evaluator - LLM-based evaluation of completed contracts against directive. //! -//! This module provides functionality for: -//! - Gathering deliverables, files, and task outputs from completed contracts -//! - Building evaluation prompts using directive and acceptance criteria -//! - Calling LLM to evaluate work against requirements -//! - Parsing evaluation responses +//! This module will be reimplemented as part of the directive verification engine. +//! See the orchestration module for the new evaluation system. +//! +//! The new evaluation system will provide: +//! - Tiered verification (programmatic verifiers first, then LLM evaluation) +//! - Composite confidence scoring (weighted combination of results) +//! - Pluggable verifier interface (test runner, linter, build, type checker) +//! - Proper integration with the directive chain steps use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use crate::db::{ - models::{ - ChainContract, ChainDirective, Contract, ContractEvaluation, CreateContractEvaluationRequest, - DirectiveAcceptanceCriterion, DirectiveRequirement, EvaluationCriterionResult, - }, - repository, -}; - -use super::claude::{ClaudeClient, ClaudeModel, Message, MessageContent}; -use super::tools::Tool; +// use crate::db::models::{Contract, DirectiveAcceptanceCriterion, DirectiveRequirement}; /// Result of contract evaluation #[derive(Debug, Clone, Serialize, Deserialize)] @@ -30,526 +24,74 @@ pub struct ContractEvaluationResult { /// Overall score from 0.0 to 1.0 pub overall_score: f64, /// Results for each acceptance criterion - pub criteria_results: Vec<EvaluationCriterionResult>, + pub criteria_results: Vec<EvaluationCriterionResultLegacy>, /// Summary feedback from the evaluator pub summary_feedback: String, /// Instructions for rework if failed pub rework_instructions: Option<String>, } -/// Context gathered for evaluation -#[derive(Debug, Clone)] -pub struct EvaluationContext { - /// The contract being evaluated - pub contract: Contract, - /// The chain contract record - pub chain_contract: ChainContract, - /// The directive document - pub directive: ChainDirective, - /// Files associated with the contract - pub files: Vec<FileContent>, - /// Task outputs from the contract - pub task_outputs: Vec<TaskOutput>, - /// Deliverables marked as complete - pub deliverables: Vec<DeliverableInfo>, - /// Acceptance criteria specific to this contract - pub acceptance_criteria: Vec<DirectiveAcceptanceCriterion>, - /// Requirements mapped to this contract - pub requirements: Vec<DirectiveRequirement>, +/// Per-criterion evaluation result (legacy - kept for compatibility) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluationCriterionResultLegacy { + pub criterion_id: String, + pub criterion_text: String, + pub passed: bool, + /// Score (0.0-1.0) + pub score: f64, + pub feedback: String, + /// Evidence supporting the evaluation + pub evidence: Vec<String>, } -/// File content for evaluation -#[derive(Debug, Clone, Serialize)] +/// File content for evaluation context +#[derive(Debug, Clone)] pub struct FileContent { pub path: String, - pub description: Option<String>, pub content: String, - pub is_deliverable: bool, } -/// Task output for evaluation -#[derive(Debug, Clone, Serialize)] -pub struct TaskOutput { - pub task_name: String, - pub output_summary: String, - pub exit_code: Option<i32>, -} - -/// Deliverable info for evaluation -#[derive(Debug, Clone, Serialize)] -pub struct DeliverableInfo { - pub name: String, - pub status: String, - pub file_path: Option<String>, -} - -/// Error types for evaluation -#[derive(Debug, thiserror::Error)] -pub enum EvaluationError { - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), - - #[error("Contract not found: {0}")] - ContractNotFound(Uuid), - - #[error("Chain contract not found for contract: {0}")] - ChainContractNotFound(Uuid), - - #[error("Directive not found for chain: {0}")] - DirectiveNotFound(Uuid), - - #[error("LLM evaluation failed: {0}")] - LlmError(String), - - #[error("Failed to parse evaluation response: {0}")] - ParseError(String), -} - -/// Contract evaluator for directive-driven evaluation +/// Contract evaluator for LLM-based assessment. +/// +/// NOTE: This is a stub implementation. The full evaluation system will be +/// implemented as part of the orchestration/verifier module. pub struct ContractEvaluator { - pool: PgPool, - claude_client: ClaudeClient, - model: ClaudeModel, - /// Minimum score required to pass (default 0.8) - pass_threshold: f64, + _pool: PgPool, } impl ContractEvaluator { - /// Create a new evaluator - pub fn new(pool: PgPool, claude_client: ClaudeClient) -> Self { - Self { - pool, - claude_client, - model: ClaudeModel::Sonnet, - pass_threshold: 0.8, - } - } - - /// Set the LLM model to use for evaluation - pub fn with_model(mut self, model: ClaudeModel) -> Self { - self.model = model; - self - } - - /// Set the pass threshold - pub fn with_pass_threshold(mut self, threshold: f64) -> Self { - self.pass_threshold = threshold; - self + /// Create a new contract evaluator. + pub fn new(pool: PgPool) -> Self { + Self { _pool: pool } } - /// Evaluate a completed contract against the directive + /// Evaluate a contract - stub implementation. + /// + /// This will be reimplemented in the orchestration module with: + /// - Programmatic verification (tests, lint, build) + /// - LLM evaluation + /// - Composite scoring pub async fn evaluate_contract( &self, - contract_id: Uuid, - owner_id: Uuid, - ) -> Result<ContractEvaluationResult, EvaluationError> { - // Gather evaluation context - let context = self.gather_context(contract_id, owner_id).await?; - - // Build evaluation prompt - let prompt = self.build_evaluation_prompt(&context); - - // Call LLM for evaluation - let response = self.call_llm_for_evaluation(&prompt).await?; - - // Parse the response - let result = self.parse_evaluation_response(&response, &context)?; - - Ok(result) - } - - /// Gather all context needed for evaluation - async fn gather_context( - &self, - contract_id: Uuid, - owner_id: Uuid, - ) -> Result<EvaluationContext, EvaluationError> { - // Get contract - let contract = repository::get_contract_for_owner(&self.pool, contract_id, owner_id) - .await? - .ok_or(EvaluationError::ContractNotFound(contract_id))?; - - // Get chain contract - let chain_contract = repository::get_chain_contract_by_contract_id(&self.pool, contract_id) - .await? - .ok_or(EvaluationError::ChainContractNotFound(contract_id))?; - - // Get directive - let directive = repository::get_chain_directive(&self.pool, chain_contract.chain_id) - .await? - .ok_or(EvaluationError::DirectiveNotFound(chain_contract.chain_id))?; - - // Get files directly from repository - let contract_files = repository::list_files_in_contract(&self.pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - // Get tasks directly from repository - let contract_tasks = repository::list_tasks_in_contract(&self.pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - // Build file contents from FileSummary - // Note: FileSummary doesn't have content, so we use name and description - let files: Vec<FileContent> = contract_files.iter().map(|f| { - FileContent { - path: f.repo_file_path.clone().unwrap_or_else(|| f.name.clone()), - description: f.description.clone(), - content: format!("[File: {} - content not loaded in summary view]", f.name), - is_deliverable: false, // FileSummary doesn't track deliverable status - } - }).collect(); - - // Build task outputs from TaskSummary - let task_outputs: Vec<TaskOutput> = contract_tasks.iter().map(|t| { - TaskOutput { - task_name: t.name.clone(), - output_summary: t.progress_summary.clone().unwrap_or_else(|| format!("Status: {}", t.status)), - exit_code: None, - } - }).collect(); - - // Build deliverables info from files marked as deliverables - // Since FileSummary doesn't have deliverable info, we treat all files as potential deliverables - let deliverables: Vec<DeliverableInfo> = contract_files.iter() - .map(|f| DeliverableInfo { - name: f.name.clone(), - status: "complete".to_string(), - file_path: f.repo_file_path.clone(), - }) - .collect(); - - // Parse requirements and acceptance criteria from directive - let requirements: Vec<DirectiveRequirement> = - serde_json::from_value(directive.requirements.clone()).unwrap_or_default(); - - let all_criteria: Vec<DirectiveAcceptanceCriterion> = - serde_json::from_value(directive.acceptance_criteria.clone()).unwrap_or_default(); - - // Get contract definition to find mapped requirements - // For now, use all acceptance criteria - let acceptance_criteria = all_criteria; - - Ok(EvaluationContext { - contract, - chain_contract, - directive, - files, - task_outputs, - deliverables, - acceptance_criteria, - requirements, - }) + _contract_id: Uuid, + ) -> Result<ContractEvaluationResult, ContractEvaluatorError> { + // TODO: Implement using the new directive evaluation system + Err(ContractEvaluatorError::NotImplemented( + "Contract evaluator will be reimplemented with directive system".to_string(), + )) } - - /// Build the evaluation prompt - fn build_evaluation_prompt(&self, context: &EvaluationContext) -> String { - let mut prompt = String::new(); - - prompt.push_str("# Contract Completion Evaluation\n\n"); - prompt.push_str("You are evaluating whether a contract has been completed successfully against its requirements.\n\n"); - - // Contract info - prompt.push_str("## Contract Information\n\n"); - prompt.push_str(&format!("**Name:** {}\n", context.contract.name)); - if let Some(ref desc) = context.contract.description { - prompt.push_str(&format!("**Description:** {}\n", desc)); - } - prompt.push_str(&format!("**Type:** {}\n", context.contract.contract_type)); - prompt.push_str(&format!("**Phase:** {}\n", context.contract.phase)); - prompt.push_str("\n"); - - // Requirements - if !context.requirements.is_empty() { - prompt.push_str("## Requirements\n\n"); - for req in &context.requirements { - prompt.push_str(&format!("- **{}** ({}): {}\n", req.id, req.priority, req.title)); - if !req.description.is_empty() { - prompt.push_str(&format!(" {}\n", req.description)); - } - } - prompt.push_str("\n"); - } - - // Acceptance criteria - if !context.acceptance_criteria.is_empty() { - prompt.push_str("## Acceptance Criteria\n\n"); - for (i, criterion) in context.acceptance_criteria.iter().enumerate() { - prompt.push_str(&format!("{}. **{}**\n", i + 1, criterion.description)); - prompt.push_str(&format!(" - Testable: {}\n", criterion.testable)); - if !criterion.requirement_ids.is_empty() { - prompt.push_str(&format!(" - Covers: {}\n", criterion.requirement_ids.join(", "))); - } - } - prompt.push_str("\n"); - } - - // Deliverables - if !context.deliverables.is_empty() { - prompt.push_str("## Deliverables\n\n"); - for d in &context.deliverables { - prompt.push_str(&format!("- {} ({})\n", d.name, d.status)); - } - prompt.push_str("\n"); - } - - // Files - if !context.files.is_empty() { - prompt.push_str("## Files Created/Modified\n\n"); - for file in &context.files { - prompt.push_str(&format!("### {}", file.path)); - if file.is_deliverable { - prompt.push_str(" [DELIVERABLE]"); - } - prompt.push_str("\n"); - if let Some(ref desc) = file.description { - prompt.push_str(&format!("*{}*\n", desc)); - } - // Truncate content if too long - let content = if file.content.len() > 5000 { - format!("{}...\n[Content truncated - {} chars total]", - &file.content[..5000], file.content.len()) - } else { - file.content.clone() - }; - prompt.push_str("```\n"); - prompt.push_str(&content); - prompt.push_str("\n```\n\n"); - } - } - - // Task outputs - if !context.task_outputs.is_empty() { - prompt.push_str("## Task Outputs\n\n"); - for task in &context.task_outputs { - prompt.push_str(&format!("### {}\n", task.task_name)); - prompt.push_str(&format!("{}\n\n", task.output_summary)); - } - } - - // Evaluation instructions - prompt.push_str("## Evaluation Instructions\n\n"); - prompt.push_str("Please evaluate the completed work against the requirements and acceptance criteria.\n\n"); - prompt.push_str("For each acceptance criterion, determine if it has been met and provide a brief explanation.\n\n"); - prompt.push_str("Respond with a JSON object in the following format:\n\n"); - prompt.push_str("```json\n"); - prompt.push_str(r#"{ - "passed": true/false, - "overallScore": 0.0-1.0, - "criteriaResults": [ - { - "criterionId": "criterion identifier or index", - "met": true/false, - "score": 0.0-1.0, - "feedback": "explanation of why criterion was/wasn't met" - } - ], - "summaryFeedback": "overall summary of the evaluation", - "reworkInstructions": "if failed, specific instructions for what needs to be fixed (null if passed)" -} -"#); - prompt.push_str("```\n\n"); - prompt.push_str(&format!("The pass threshold is {}. ", self.pass_threshold)); - prompt.push_str("A contract passes if the overall score is >= the threshold AND all critical criteria are met.\n"); - - prompt - } - - /// Call LLM for evaluation - async fn call_llm_for_evaluation(&self, prompt: &str) -> Result<String, EvaluationError> { - let messages = vec![Message { - role: "user".to_string(), - content: MessageContent::Text(prompt.to_string()), - }]; - - // Use chat_with_tools with empty tools array for simple chat - let empty_tools: Vec<Tool> = vec![]; - let result = self - .claude_client - .chat_with_tools(messages, &empty_tools) - .await - .map_err(|e| EvaluationError::LlmError(e.to_string()))?; - - // ChatResult.content is already an Option<String> - let text = result.content.unwrap_or_default(); - - Ok(text) - } - - /// Parse the LLM response into an evaluation result - fn parse_evaluation_response( - &self, - response: &str, - context: &EvaluationContext, - ) -> Result<ContractEvaluationResult, EvaluationError> { - // Extract JSON from response (may be wrapped in markdown code blocks) - let json_str = extract_json_from_response(response)?; - - // Parse the JSON - let parsed: EvaluationResponseJson = serde_json::from_str(&json_str) - .map_err(|e| EvaluationError::ParseError(format!("JSON parse error: {}", e)))?; - - // Convert to our result type - let criteria_results: Vec<EvaluationCriterionResult> = parsed - .criteria_results - .into_iter() - .map(|cr| EvaluationCriterionResult { - criterion_id: cr.criterion_id.clone(), - criterion_text: cr.criterion_id, // Use ID as text if not provided - passed: cr.passed, - score: cr.score, - feedback: cr.feedback, - evidence: vec![], - }) - .collect(); - - // Determine pass/fail based on threshold and results - let passed = parsed.passed && parsed.overall_score >= self.pass_threshold; - - Ok(ContractEvaluationResult { - passed, - overall_score: parsed.overall_score, - criteria_results, - summary_feedback: parsed.summary_feedback, - rework_instructions: if passed { None } else { parsed.rework_instructions }, - }) - } - - /// Save evaluation result to database - pub async fn save_evaluation( - &self, - contract_id: Uuid, - chain_id: Uuid, - chain_contract_id: Uuid, - result: &ContractEvaluationResult, - ) -> Result<ContractEvaluation, EvaluationError> { - let req = CreateContractEvaluationRequest { - contract_id, - chain_id: Some(chain_id), - chain_contract_id: Some(chain_contract_id), - evaluator_model: Some(format!("{:?}", self.model)), - passed: result.passed, - overall_score: Some(result.overall_score), - criteria_results: result.criteria_results.clone(), - summary_feedback: result.summary_feedback.clone(), - rework_instructions: result.rework_instructions.clone(), - }; - - let evaluation = repository::create_contract_evaluation(&self.pool, req).await?; - - // Update chain contract status - let status = if result.passed { "passed" } else { "failed" }; - repository::update_chain_contract_evaluation_status( - &self.pool, - chain_contract_id, - status, - Some(evaluation.id), - result.rework_instructions.as_deref(), - ) - .await?; - - Ok(evaluation) - } -} - -/// JSON structure for parsing LLM response -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EvaluationResponseJson { - passed: bool, - overall_score: f64, - criteria_results: Vec<CriterionResultJson>, - summary_feedback: String, - rework_instructions: Option<String>, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CriterionResultJson { - criterion_id: String, - #[serde(alias = "met")] - passed: bool, - #[serde(default)] - score: f64, - feedback: String, -} - -/// Extract JSON from a response that may contain markdown code blocks -fn extract_json_from_response(response: &str) -> Result<String, EvaluationError> { - // Try to find JSON in code blocks first - if let Some(start) = response.find("```json") { - let json_start = start + 7; - if let Some(end) = response[json_start..].find("```") { - return Ok(response[json_start..json_start + end].trim().to_string()); - } - } - - // Try plain code blocks - if let Some(start) = response.find("```") { - let json_start = start + 3; - // Skip any language identifier on the same line - let actual_start = response[json_start..] - .find('\n') - .map(|i| json_start + i + 1) - .unwrap_or(json_start); - if let Some(end) = response[actual_start..].find("```") { - return Ok(response[actual_start..actual_start + end].trim().to_string()); - } - } - - // Try to find raw JSON (starts with {) - if let Some(start) = response.find('{') { - // Find matching closing brace - let mut depth = 0; - let mut end = start; - for (i, c) in response[start..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = start + i + 1; - break; - } - } - _ => {} - } - } - if end > start { - return Ok(response[start..end].to_string()); - } - } - - Err(EvaluationError::ParseError( - "Could not find JSON in response".to_string(), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_json_from_code_block() { - let response = r#"Here is the evaluation: - -```json -{ - "passed": true, - "overallScore": 0.85 } -``` -Done."#; +/// Error types for contract evaluation. +#[derive(Debug, thiserror::Error)] +pub enum ContractEvaluatorError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), - let json = extract_json_from_response(response).unwrap(); - assert!(json.contains("\"passed\": true")); - } + #[error("LLM error: {0}")] + Llm(String), - #[test] - fn test_extract_json_raw() { - let response = r#"The result is {"passed": false, "overallScore": 0.5}"#; - let json = extract_json_from_response(response).unwrap(); - assert!(json.contains("\"passed\": false")); - } + #[error("Not implemented: {0}")] + NotImplemented(String), } diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs index 702e1fd..6c9965c 100644 --- a/makima/src/llm/mod.rs +++ b/makima/src/llm/mod.rs @@ -46,7 +46,7 @@ pub use transcript_analyzer::{ calculate_speaker_stats, build_analysis_prompt, parse_analysis_response, }; pub use contract_evaluator::{ - ContractEvaluator, ContractEvaluationResult, EvaluationContext, EvaluationError, + ContractEvaluator, ContractEvaluationResult, ContractEvaluatorError, }; /// Available LLM providers and models diff --git a/makima/src/llm/task_output.rs b/makima/src/llm/task_output.rs index c5d709e..c7f6990 100644 --- a/makima/src/llm/task_output.rs +++ b/makima/src/llm/task_output.rs @@ -126,7 +126,7 @@ pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult { let heading_pattern = Regex::new(r"^##\s+(?:Phase\s*\d*:?\s*)?(.+)$").unwrap(); // Patterns for dependencies (inline) - let depends_pattern = Regex::new(r"(?i)(?:depends on|after|requires):?\s*(.+)").unwrap(); + let depends_pattern = Regex::new(r"(?i)\(?\s*(?:depends on|after|requires):?\s*([^)]+)\)?").unwrap(); for line in content.lines() { let trimmed = line.trim(); @@ -226,7 +226,7 @@ pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult { } } -/// Check if text looks like a task (has action verbs) +/// Check if text looks like a task (has action verbs at word boundaries) fn looks_like_task(text: &str) -> bool { let lower = text.to_lowercase(); let action_verbs = [ @@ -237,7 +237,27 @@ fn looks_like_task(text: &str) -> bool { "disable", "install", "initialize", "define", "extend", "extract", ]; - action_verbs.iter().any(|verb| lower.starts_with(verb) || lower.contains(&format!(" {}", verb))) + // Check if text starts with an action verb (followed by space or end) + for verb in &action_verbs { + if lower.starts_with(verb) { + // Check for word boundary after verb + let after = &lower[verb.len()..]; + if after.is_empty() || after.starts_with(' ') || after.starts_with('_') { + return true; + } + } + // Check if verb appears after space with word boundary + let pattern = format!(" {} ", verb); + let pattern_end = format!(" {}", verb); + if lower.contains(&pattern) { + return true; + } + // Check if verb is at the end of string after a space + if lower.ends_with(&pattern_end) && lower.len() > pattern_end.len() { + return true; + } + } + false } /// Analyze a completed task's output to suggest next actions diff --git a/makima/src/orchestration/engine.rs b/makima/src/orchestration/engine.rs new file mode 100644 index 0000000..5bbb99f --- /dev/null +++ b/makima/src/orchestration/engine.rs @@ -0,0 +1,976 @@ +//! Directive orchestration engine. +//! +//! Manages the lifecycle of directives: +//! - Starts directives and generates initial chains +//! - Monitors step execution and triggers evaluations +//! - Handles rework, escalation, and chain regeneration +//! - Enforces circuit breakers (cost, time, rework limits) + +use std::collections::HashMap; + +use sqlx::PgPool; +use thiserror::Error; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::db::models::{AddStepRequest, ChainStep, Directive, DirectiveEvent, UpdateStepRequest}; +use crate::db::repository::{self, RepositoryError}; + +use super::planner::{ChainPlanner, GeneratedChain, PlannerError}; +use super::verifier::{ + auto_detect_verifiers, CompositeEvaluator, ConfidenceLevel, EvaluationResult, + VerificationContext, +}; + +/// Error type for engine operations. +#[derive(Error, Debug)] +pub enum EngineError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Repository error: {0}")] + Repository(#[from] RepositoryError), + + #[error("Planner error: {0}")] + Planner(#[from] PlannerError), + + #[error("Directive not found: {0}")] + DirectiveNotFound(Uuid), + + #[error("Chain not found for directive: {0}")] + ChainNotFound(Uuid), + + #[error("Step not found: {0}")] + StepNotFound(Uuid), + + #[error("Invalid state transition: {from} -> {to}")] + InvalidStateTransition { from: String, to: String }, + + #[error("Circuit breaker triggered: {0}")] + CircuitBreaker(String), + + #[error("Directive is paused")] + DirectivePaused, + + #[error("Contract creation failed: {0}")] + ContractCreation(String), + + #[error("LLM error: {0}")] + LlmError(String), +} + +/// Event emitted by the engine for UI updates. +#[derive(Debug, Clone)] +pub enum EngineEvent { + /// Directive status changed + DirectiveStatusChanged { + directive_id: Uuid, + old_status: String, + new_status: String, + }, + /// Step status changed + StepStatusChanged { + directive_id: Uuid, + step_id: Uuid, + old_status: String, + new_status: String, + }, + /// Evaluation completed + EvaluationCompleted { + directive_id: Uuid, + step_id: Uuid, + passed: bool, + confidence_level: ConfidenceLevel, + }, + /// Approval required + ApprovalRequired { + directive_id: Uuid, + approval_id: Uuid, + approval_type: String, + }, + /// Chain regenerated + ChainRegenerated { + directive_id: Uuid, + old_chain_id: Uuid, + new_chain_id: Uuid, + }, +} + +/// Main orchestration engine for directives. +pub struct DirectiveEngine { + pool: PgPool, + planner: ChainPlanner, + event_tx: Option<broadcast::Sender<EngineEvent>>, +} + +impl DirectiveEngine { + /// Create a new directive engine. + pub fn new(pool: PgPool) -> Self { + Self { + pool, + planner: ChainPlanner::new(), + event_tx: None, + } + } + + /// Set the event broadcast channel for UI updates. + pub fn with_event_channel(mut self, tx: broadcast::Sender<EngineEvent>) -> Self { + self.event_tx = Some(tx); + self + } + + /// Emit an event if channel is configured. + fn emit_event(&self, event: EngineEvent) { + if let Some(tx) = &self.event_tx { + let _ = tx.send(event); + } + } + + // ======================================================================== + // Directive Lifecycle + // ======================================================================== + + /// Start a directive: generate chain and begin execution. + pub async fn start_directive(&self, directive_id: Uuid) -> Result<(), EngineError> { + let directive = repository::get_directive(&self.pool, directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(directive_id))?; + + // Validate current state + if directive.status != "draft" && directive.status != "paused" { + return Err(EngineError::InvalidStateTransition { + from: directive.status, + to: "planning".to_string(), + }); + } + + // Update status to planning + repository::update_directive_status(&self.pool, directive_id, "planning").await?; + self.emit_directive_event( + directive_id, + "status_changed", + "info", + serde_json::json!({"old_status": directive.status, "new_status": "planning"}), + "system", + ) + .await?; + + // Generate chain (placeholder - actual LLM call would go here) + let chain = self.generate_initial_chain(&directive).await?; + + // Create chain in database + let db_chain = repository::create_directive_chain( + &self.pool, + directive_id, + &chain.name, + Some(&chain.description), + None, // rationale + None, // planning_model + ) + .await?; + + // Create steps + self.create_steps_from_chain(&db_chain.id, &chain).await?; + + // Update directive to active + repository::update_directive_status(&self.pool, directive_id, "active").await?; + self.emit_event(EngineEvent::DirectiveStatusChanged { + directive_id, + old_status: "planning".to_string(), + new_status: "active".to_string(), + }); + + // Start ready steps + self.advance_chain(directive_id).await?; + + Ok(()) + } + + /// Pause a directive. + pub async fn pause_directive(&self, directive_id: Uuid) -> Result<(), EngineError> { + let directive = repository::get_directive(&self.pool, directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(directive_id))?; + + if directive.status != "active" { + return Err(EngineError::InvalidStateTransition { + from: directive.status, + to: "paused".to_string(), + }); + } + + repository::update_directive_status(&self.pool, directive_id, "paused").await?; + self.emit_event(EngineEvent::DirectiveStatusChanged { + directive_id, + old_status: "active".to_string(), + new_status: "paused".to_string(), + }); + + Ok(()) + } + + /// Resume a paused directive. + pub async fn resume_directive(&self, directive_id: Uuid) -> Result<(), EngineError> { + let directive = repository::get_directive(&self.pool, directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(directive_id))?; + + if directive.status != "paused" { + return Err(EngineError::InvalidStateTransition { + from: directive.status, + to: "active".to_string(), + }); + } + + repository::update_directive_status(&self.pool, directive_id, "active").await?; + self.emit_event(EngineEvent::DirectiveStatusChanged { + directive_id, + old_status: "paused".to_string(), + new_status: "active".to_string(), + }); + + // Continue execution + self.advance_chain(directive_id).await?; + + Ok(()) + } + + /// Stop a directive (cannot be resumed). + pub async fn stop_directive(&self, directive_id: Uuid) -> Result<(), EngineError> { + let directive = repository::get_directive(&self.pool, directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(directive_id))?; + + if directive.status == "completed" || directive.status == "failed" { + return Err(EngineError::InvalidStateTransition { + from: directive.status, + to: "failed".to_string(), + }); + } + + repository::update_directive_status(&self.pool, directive_id, "failed").await?; + self.emit_event(EngineEvent::DirectiveStatusChanged { + directive_id, + old_status: directive.status, + new_status: "failed".to_string(), + }); + + Ok(()) + } + + // ======================================================================== + // Chain Management + // ======================================================================== + + /// Generate initial chain from directive. + async fn generate_initial_chain( + &self, + directive: &Directive, + ) -> Result<GeneratedChain, EngineError> { + // Build planning prompt + let _prompt = self.planner.build_planning_prompt(directive); + + // TODO: Call LLM to generate chain + // For now, return a simple placeholder chain + let chain = GeneratedChain { + name: format!("{}-chain", directive.title.to_lowercase().replace(' ', "-")), + description: format!("Execution plan for: {}", directive.goal), + steps: vec![ + super::planner::GeneratedStep { + name: "research".to_string(), + step_type: "research".to_string(), + description: "Research and understand the requirements".to_string(), + depends_on: vec![], + requirement_ids: vec![], + contract_template: None, + }, + super::planner::GeneratedStep { + name: "implement".to_string(), + step_type: "implement".to_string(), + description: "Implement the solution".to_string(), + depends_on: vec!["research".to_string()], + requirement_ids: vec![], + contract_template: None, + }, + super::planner::GeneratedStep { + name: "test".to_string(), + step_type: "test".to_string(), + description: "Test and verify the implementation".to_string(), + depends_on: vec!["implement".to_string()], + requirement_ids: vec![], + contract_template: None, + }, + ], + }; + + // Validate the chain + self.planner.validate_chain(&chain)?; + + Ok(chain) + } + + /// Create database steps from a generated chain. + async fn create_steps_from_chain( + &self, + chain_id: &Uuid, + chain: &GeneratedChain, + ) -> Result<(), EngineError> { + // First pass: create all steps and build name-to-id map + let mut step_id_map: HashMap<String, Uuid> = HashMap::new(); + + // Get editor positions + let positions = self.planner.compute_editor_positions(chain); + + for step in &chain.steps { + let (editor_x, editor_y) = positions + .get(&step.name) + .copied() + .unwrap_or((100.0, 100.0)); + + let task_plan = step + .contract_template + .as_ref() + .and_then(|t| t.tasks.first()) + .map(|t| t.plan.clone()) + .or_else(|| Some(step.description.clone())); + + let request = AddStepRequest { + name: step.name.clone(), + description: Some(step.description.clone()), + step_type: Some(step.step_type.clone()), + contract_type: step.contract_template.as_ref().map(|t| t.contract_type.clone()), + initial_phase: Some("plan".to_string()), + task_plan, + phases: step.contract_template.as_ref().map(|t| t.phases.clone()), + depends_on: None, // Will update in second pass + parallel_group: None, + requirement_ids: Some(step.requirement_ids.clone()), + acceptance_criteria_ids: None, + verifier_config: None, + editor_x: Some(editor_x), + editor_y: Some(editor_y), + }; + + let db_step = repository::create_chain_step(&self.pool, *chain_id, request).await?; + step_id_map.insert(step.name.clone(), db_step.id); + } + + // Second pass: update dependencies + for step in &chain.steps { + if step.depends_on.is_empty() { + continue; + } + + let step_id = step_id_map.get(&step.name).unwrap(); + let dep_ids: Vec<Uuid> = step + .depends_on + .iter() + .filter_map(|name| step_id_map.get(name)) + .copied() + .collect(); + + // Update step with proper dependencies + let update = UpdateStepRequest { + name: None, + description: None, + task_plan: None, + depends_on: Some(dep_ids), + requirement_ids: None, + acceptance_criteria_ids: None, + verifier_config: None, + editor_x: None, + editor_y: None, + }; + + repository::update_chain_step(&self.pool, *step_id, update).await?; + } + + Ok(()) + } + + /// Regenerate chain while preserving completed steps. + pub async fn regenerate_chain( + &self, + directive_id: Uuid, + reason: &str, + ) -> Result<Uuid, EngineError> { + let directive = repository::get_directive(&self.pool, directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(directive_id))?; + + let current_chain = repository::get_current_chain(&self.pool, directive_id) + .await? + .ok_or(EngineError::ChainNotFound(directive_id))?; + + // Get completed and failed steps + let steps = repository::list_chain_steps(&self.pool, current_chain.id).await?; + let completed_steps: Vec<_> = steps.iter().filter(|s| s.status == "passed").collect(); + let failed_step = steps.iter().find(|s| s.status == "failed"); + + // Build replan prompt + let _prompt = self.planner.build_replan_prompt( + &directive, + &completed_steps.iter().map(|s| (*s).clone()).collect::<Vec<_>>(), + failed_step.map(|s| &*s), + reason, + ); + + // TODO: Call LLM to regenerate chain + // For now, just create a new chain with similar structure + let new_chain = self.generate_initial_chain(&directive).await?; + + // Supersede old chain + repository::supersede_chain(&self.pool, current_chain.id).await?; + + // Create new chain + let db_chain = repository::create_directive_chain( + &self.pool, + directive_id, + &new_chain.name, + Some(&new_chain.description), + Some(reason), // rationale + None, // planning_model + ) + .await?; + + // Create steps + self.create_steps_from_chain(&db_chain.id, &new_chain).await?; + + self.emit_event(EngineEvent::ChainRegenerated { + directive_id, + old_chain_id: current_chain.id, + new_chain_id: db_chain.id, + }); + + // Continue execution + self.advance_chain(directive_id).await?; + + Ok(db_chain.id) + } + + // ======================================================================== + // Step Execution + // ======================================================================== + + /// Advance chain execution: find ready steps and start them. + pub async fn advance_chain(&self, directive_id: Uuid) -> Result<(), EngineError> { + let directive = repository::get_directive(&self.pool, directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(directive_id))?; + + // Check if directive is active + if directive.status == "paused" { + return Err(EngineError::DirectivePaused); + } + if directive.status != "active" { + return Ok(()); // Not an error, just nothing to do + } + + // Check circuit breakers + self.check_circuit_breakers(&directive).await?; + + // Get current chain + let chain = repository::get_current_chain(&self.pool, directive_id) + .await? + .ok_or(EngineError::ChainNotFound(directive_id))?; + + // Find ready steps (dependencies met, status=pending) + let ready_steps = repository::find_ready_steps(&self.pool, chain.id).await?; + + // Start each ready step + for step in ready_steps { + self.start_step(&directive, &step).await?; + } + + // Check if chain is complete + let all_steps = repository::list_chain_steps(&self.pool, chain.id).await?; + let all_passed = all_steps.iter().all(|s| s.status == "passed" || s.status == "skipped"); + let any_blocked = all_steps.iter().any(|s| s.status == "blocked" || s.status == "failed"); + + if all_passed && !all_steps.is_empty() { + // Complete the directive + self.complete_directive(directive_id).await?; + } else if any_blocked { + // Check if we should regenerate or fail + let failed_count = all_steps.iter().filter(|s| s.status == "failed").count(); + if failed_count > 3 { + // Too many failures, fail the directive + repository::update_directive_status(&self.pool, directive_id, "failed").await?; + } + } + + Ok(()) + } + + /// Start a step by creating its contract and supervisor task. + async fn start_step(&self, directive: &Directive, step: &ChainStep) -> Result<(), EngineError> { + // Update step status to ready + repository::update_step_status(&self.pool, step.id, "ready").await?; + self.emit_event(EngineEvent::StepStatusChanged { + directive_id: directive.id, + step_id: step.id, + old_status: "pending".to_string(), + new_status: "ready".to_string(), + }); + + // Get contract details from step template + let (_name, _description, _contract_type, _initial_phase) = + self.get_contract_details(directive, step); + + // TODO: Actually create the contract via the contracts handler + // For now, just update the step status to running + // In a full implementation, this would: + // 1. Create contract via POST /api/v1/contracts + // 2. Create supervisor task via POST /api/v1/tasks + // 3. Link contract and task to step + // 4. Update step status to running + + // Placeholder: mark step as running + repository::update_step_status(&self.pool, step.id, "running").await?; + self.emit_event(EngineEvent::StepStatusChanged { + directive_id: directive.id, + step_id: step.id, + old_status: "ready".to_string(), + new_status: "running".to_string(), + }); + + self.emit_directive_event( + directive.id, + "step_started", + "info", + serde_json::json!({ + "step_id": step.id, + "step_name": step.name, + }), + "system", + ) + .await?; + + Ok(()) + } + + /// Build contract details from a step. + /// Returns (name, description, contract_type, initial_phase) + fn get_contract_details( + &self, + directive: &Directive, + step: &ChainStep, + ) -> (String, Option<String>, String, String) { + let name = format!("{} - {}", directive.title, step.name); + let description = step.description.clone(); + let contract_type = step.contract_type.clone(); + let initial_phase = step.initial_phase.clone().unwrap_or_else(|| "plan".to_string()); + + (name, description, contract_type, initial_phase) + } + + // ======================================================================== + // Evaluation + // ======================================================================== + + /// Handle contract completion: evaluate the step. + pub async fn on_contract_completed( + &self, + contract_id: Uuid, + ) -> Result<(), EngineError> { + // Find the step for this contract + let step = repository::get_step_by_contract_id(&self.pool, contract_id) + .await? + .ok_or(EngineError::StepNotFound(contract_id))?; + + // Get directive + let chain = repository::get_directive_chain(&self.pool, step.chain_id) + .await? + .ok_or(EngineError::ChainNotFound(step.chain_id))?; + + let directive = repository::get_directive(&self.pool, chain.directive_id) + .await? + .ok_or(EngineError::DirectiveNotFound(chain.directive_id))?; + + // Update step status to evaluating + repository::update_step_status(&self.pool, step.id, "evaluating").await?; + self.emit_event(EngineEvent::StepStatusChanged { + directive_id: directive.id, + step_id: step.id, + old_status: "running".to_string(), + new_status: "evaluating".to_string(), + }); + + // Run evaluation + let result = self.evaluate_step(&directive, &step).await?; + + // Record evaluation + let programmatic_results = result + .verifier_results + .iter() + .filter(|r| r.verifier_type != super::verifier::VerifierType::Llm) + .map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null)) + .collect::<Vec<_>>(); + + let llm_results = result + .verifier_results + .iter() + .filter(|r| r.verifier_type == super::verifier::VerifierType::Llm) + .map(|r| serde_json::to_value(r).unwrap_or(serde_json::Value::Null)) + .collect::<Vec<_>>(); + + // Get chain_id from step + let chain_id = step.chain_id; + + let _evaluation = repository::create_directive_evaluation( + &self.pool, + directive.id, + Some(chain_id), + Some(step.id), + step.contract_id, + "composite", + Some("orchestration_engine"), + result.passed, + Some(result.composite_score), + Some(result.confidence_level.as_str()), + serde_json::Value::Array(programmatic_results), + serde_json::Value::Array(llm_results), + serde_json::Value::Null, // criteria_results + &result.summary, + result.rework_instructions.as_deref(), + ) + .await?; + + // Update step based on result + let new_status = match result.confidence_level { + ConfidenceLevel::Green => "passed", + ConfidenceLevel::Yellow => { + // Check autonomy level + if directive.autonomy_level == "full_auto" { + "passed" // Accept yellow in full auto mode + } else { + // Create approval request + self.request_approval( + &directive, + &step, + "step_review", + &format!( + "Step '{}' completed with yellow confidence ({:.0}%). Review required.", + step.name, + result.composite_score * 100.0 + ), + ) + .await?; + "evaluating" // Wait for approval + } + } + ConfidenceLevel::Red => { + // Initiate rework + self.initiate_rework(&directive, &step, &result).await?; + "rework" + } + }; + + repository::update_step_status(&self.pool, step.id, new_status).await?; + repository::update_step_confidence( + &self.pool, + step.id, + result.composite_score, + result.confidence_level.as_str(), + result.id, + ) + .await?; + + self.emit_event(EngineEvent::EvaluationCompleted { + directive_id: directive.id, + step_id: step.id, + passed: result.passed, + confidence_level: result.confidence_level, + }); + + // If passed, continue chain execution + if new_status == "passed" { + self.advance_chain(directive.id).await?; + } + + Ok(()) + } + + /// Evaluate a step using tiered verification. + async fn evaluate_step( + &self, + directive: &Directive, + step: &ChainStep, + ) -> Result<EvaluationResult, EngineError> { + // Get repository path + let repo_path = directive + .local_path + .as_ref() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + // Auto-detect verifiers + let verifiers = auto_detect_verifiers(&repo_path).await; + + // Build verification context + let context = VerificationContext { + step_id: step.id, + contract_id: step.contract_id, + modified_files: vec![], // TODO: Get from contract/git + step_description: step.description.clone().unwrap_or_default(), + acceptance_criteria: vec![], // TODO: Get from directive + directive_context: directive.goal.clone(), + }; + + // Run composite evaluation + let evaluator = CompositeEvaluator::new(verifiers) + .with_thresholds( + directive.confidence_threshold_green, + directive.confidence_threshold_yellow, + ); + + Ok(evaluator.evaluate(&repo_path, &context).await) + } + + /// Initiate rework for a failed step. + async fn initiate_rework( + &self, + directive: &Directive, + step: &ChainStep, + result: &EvaluationResult, + ) -> Result<(), EngineError> { + // Increment rework count + let updated_step = repository::increment_step_rework_count(&self.pool, step.id).await?; + + // Check rework limit + let max_rework = directive.max_rework_cycles.unwrap_or(3); + if updated_step.rework_count >= max_rework { + // Too many rework attempts, mark as blocked + repository::update_step_status(&self.pool, step.id, "blocked").await?; + self.emit_directive_event( + directive.id, + "step_blocked", + "warning", + serde_json::json!({ + "step_id": step.id, + "step_name": step.name, + "reason": "Max rework attempts reached", + }), + "system", + ) + .await?; + return Ok(()); + } + + // Log rework event + self.emit_directive_event( + directive.id, + "step_rework", + "info", + serde_json::json!({ + "step_id": step.id, + "step_name": step.name, + "rework_count": updated_step.rework_count, + "instructions": result.rework_instructions, + }), + "system", + ) + .await?; + + // TODO: Send rework instructions to supervisor task + // This would involve: + // 1. Reset contract phase to 'plan' + // 2. Send message to supervisor with rework instructions + // 3. Update step status to 'running' + + Ok(()) + } + + /// Request human approval for a step. + async fn request_approval( + &self, + directive: &Directive, + step: &ChainStep, + approval_type: &str, + description: &str, + ) -> Result<Uuid, EngineError> { + let context = serde_json::json!({ + "step_id": step.id, + "step_name": step.name, + "confidence_score": step.confidence_score, + }); + + let approval = repository::create_approval_request( + &self.pool, + directive.id, + Some(step.id), + approval_type, + description, + Some(context), + "medium", + None, // expires_at + ) + .await?; + + self.emit_event(EngineEvent::ApprovalRequired { + directive_id: directive.id, + approval_id: approval.id, + approval_type: approval_type.to_string(), + }); + + Ok(approval.id) + } + + /// Handle approval resolution. + pub async fn on_approval_resolved( + &self, + approval_id: Uuid, + approved: bool, + responded_by: Uuid, + ) -> Result<(), EngineError> { + let status = if approved { "approved" } else { "denied" }; + let approval = repository::resolve_approval( + &self.pool, + approval_id, + status, + None, + responded_by, + ) + .await?; + + if let Some(step_id) = approval.step_id { + let step = repository::get_chain_step(&self.pool, step_id) + .await? + .ok_or(EngineError::StepNotFound(step_id))?; + + let chain = repository::get_directive_chain(&self.pool, step.chain_id) + .await? + .ok_or(EngineError::ChainNotFound(step.chain_id))?; + + if approved { + // Mark step as passed and continue + repository::update_step_status(&self.pool, step_id, "passed").await?; + self.advance_chain(chain.directive_id).await?; + } else { + // Mark step as failed/blocked + repository::update_step_status(&self.pool, step_id, "blocked").await?; + } + } + + Ok(()) + } + + // ======================================================================== + // Circuit Breakers + // ======================================================================== + + /// Check circuit breakers for a directive. + async fn check_circuit_breakers(&self, directive: &Directive) -> Result<(), EngineError> { + // Check cost limit + if let Some(max_cost) = directive.max_total_cost_usd { + let current_cost = directive.total_cost_usd; + if current_cost >= max_cost { + return Err(EngineError::CircuitBreaker(format!( + "Cost limit exceeded: ${:.2} >= ${:.2}", + current_cost, max_cost + ))); + } + } + + // Check time limit (stored in minutes) + if let Some(max_minutes) = directive.max_wall_time_minutes { + if let Some(started_at) = directive.started_at { + let elapsed = chrono::Utc::now().signed_duration_since(started_at); + let elapsed_minutes = elapsed.num_minutes(); + if elapsed_minutes >= max_minutes as i64 { + return Err(EngineError::CircuitBreaker(format!( + "Time limit exceeded: {} min >= {} min", + elapsed_minutes, max_minutes + ))); + } + } + } + + // Check chain generation limit + if let Some(max_gen) = directive.max_chain_regenerations { + let current_gen = directive.chain_generation_count; + if current_gen >= max_gen { + return Err(EngineError::CircuitBreaker(format!( + "Chain generation limit exceeded: {} >= {}", + current_gen, max_gen + ))); + } + } + + Ok(()) + } + + // ======================================================================== + // Completion + // ======================================================================== + + /// Complete a directive after all steps pass. + async fn complete_directive(&self, directive_id: Uuid) -> Result<(), EngineError> { + // Run final evaluation (optional) + // TODO: LLM evaluation of overall directive completion + + // Update directive status + repository::update_directive_status(&self.pool, directive_id, "completed").await?; + + self.emit_event(EngineEvent::DirectiveStatusChanged { + directive_id, + old_status: "active".to_string(), + new_status: "completed".to_string(), + }); + + self.emit_directive_event( + directive_id, + "directive_completed", + "info", + serde_json::json!({}), + "system", + ) + .await?; + + Ok(()) + } + + // ======================================================================== + // Event Logging + // ======================================================================== + + /// Emit a directive event to the database. + async fn emit_directive_event( + &self, + directive_id: Uuid, + event_type: &str, + severity: &str, + event_data: serde_json::Value, + actor_type: &str, + ) -> Result<DirectiveEvent, EngineError> { + Ok(repository::emit_directive_event( + &self.pool, + directive_id, + None, // chain_id + None, // step_id + event_type, + severity, + Some(event_data), + actor_type, + None, // actor_id + ) + .await?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confidence_level_decision() { + // Green confidence should pass in all modes + assert_eq!(ConfidenceLevel::Green.as_str(), "green"); + + // Yellow confidence behavior depends on autonomy level + assert_eq!(ConfidenceLevel::Yellow.as_str(), "yellow"); + + // Red confidence should always trigger rework + assert_eq!(ConfidenceLevel::Red.as_str(), "red"); + } +} diff --git a/makima/src/orchestration/mod.rs b/makima/src/orchestration/mod.rs new file mode 100644 index 0000000..41913ca --- /dev/null +++ b/makima/src/orchestration/mod.rs @@ -0,0 +1,26 @@ +//! Orchestration engine for directive-driven autonomous execution. +//! +//! This module provides the core orchestration capabilities: +//! - [`DirectiveEngine`]: Main orchestration loop that manages directive lifecycle +//! - [`ChainPlanner`]: LLM-based chain generation from directive goals +//! - [`Verifier`]: Pluggable verification system for step validation +//! +//! # Architecture +//! +//! The orchestration system follows a directive-first approach: +//! 1. Directives define goals, requirements, and acceptance criteria +//! 2. Chains are generated execution plans (DAGs of steps) +//! 3. Steps map to contracts that are created and monitored +//! 4. Tiered verification (programmatic first, then LLM) determines confidence +//! 5. Confidence scoring (green/yellow/red) drives autonomy decisions + +mod engine; +mod planner; +mod verifier; + +pub use engine::{DirectiveEngine, EngineError}; +pub use planner::{ChainPlanner, PlannerError}; +pub use verifier::{ + auto_detect_verifiers, CompositeEvaluator, ConfidenceLevel, EvaluationResult, Verifier, + VerifierError, VerifierResult, VerifierType, +}; diff --git a/makima/src/orchestration/planner.rs b/makima/src/orchestration/planner.rs new file mode 100644 index 0000000..cdca8a0 --- /dev/null +++ b/makima/src/orchestration/planner.rs @@ -0,0 +1,742 @@ +//! Chain planner for LLM-based execution plan generation. +//! +//! Generates chains (DAGs of steps) from directive goals and requirements. +//! Supports both initial plan generation and replanning while preserving +//! completed work. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; +use uuid::Uuid; + +use crate::db::models::{AddStepRequest, ChainStep, Directive}; + +/// Error type for planner operations. +#[derive(Error, Debug)] +pub enum PlannerError { + #[error("Cycle detected in DAG: {0}")] + CycleDetected(String), + + #[error("Invalid dependency: step '{step}' depends on unknown step '{dependency}'")] + InvalidDependency { step: String, dependency: String }, + + #[error("LLM generation failed: {0}")] + LlmError(String), + + #[error("Requirement not covered: {0}")] + RequirementNotCovered(String), + + #[error("Invalid plan: {0}")] + InvalidPlan(String), + + #[error("Empty plan generated")] + EmptyPlan, +} + +/// Generated step from LLM planning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneratedStep { + /// Unique name within the chain + pub name: String, + /// Type of step (e.g., "research", "implement", "test", "review") + pub step_type: String, + /// Description of what this step accomplishes + pub description: String, + /// Names of steps this depends on + pub depends_on: Vec<String>, + /// IDs of requirements this step addresses + pub requirement_ids: Vec<String>, + /// Contract template fields + pub contract_template: Option<ContractTemplate>, +} + +/// Template for contract creation from step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractTemplate { + /// Contract name + pub name: String, + /// Contract description + pub description: String, + /// Contract type (e.g., "simple", "agentic") + pub contract_type: String, + /// Phases for the contract + pub phases: Vec<String>, + /// Tasks within the contract + pub tasks: Vec<TaskTemplate>, + /// Deliverables expected + pub deliverables: Vec<DeliverableTemplate>, +} + +/// Template for task within contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskTemplate { + pub name: String, + pub plan: String, +} + +/// Template for deliverable. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeliverableTemplate { + pub id: String, + pub name: String, + pub priority: String, +} + +/// Generated chain from planning. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeneratedChain { + /// Name for the chain + pub name: String, + /// Description of the execution plan + pub description: String, + /// Steps in the chain + pub steps: Vec<GeneratedStep>, +} + +/// Chain planner for LLM-based plan generation. +pub struct ChainPlanner { + /// Default step types to suggest (reserved for future use) + #[allow(dead_code)] + default_step_types: Vec<String>, +} + +impl Default for ChainPlanner { + fn default() -> Self { + Self::new() + } +} + +impl ChainPlanner { + /// Create a new chain planner. + pub fn new() -> Self { + Self { + default_step_types: vec![ + "research".to_string(), + "design".to_string(), + "implement".to_string(), + "test".to_string(), + "review".to_string(), + "document".to_string(), + ], + } + } + + /// Build a planning prompt for the LLM. + pub fn build_planning_prompt(&self, directive: &Directive) -> String { + let requirements: Vec<String> = directive + .requirements + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_object()) + .map(|obj| { + let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let desc = obj + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + format!("- {}: {}", id, desc) + }) + .collect() + }) + .unwrap_or_default(); + + let criteria: Vec<String> = directive + .acceptance_criteria + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_object()) + .map(|obj| { + let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let criterion = obj + .get("criterion") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + format!("- {}: {}", id, criterion) + }) + .collect() + }) + .unwrap_or_default(); + + let constraints: Vec<String> = directive + .constraints + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("- {}", s)) + .collect() + }) + .unwrap_or_default(); + + format!( + r#"You are a software architect planning an execution chain for a coding task. + +## Directive Goal +{goal} + +## Requirements +{requirements} + +## Acceptance Criteria +{criteria} + +## Constraints +{constraints} + +## Instructions + +Create an execution plan as a chain of steps. Each step should: +1. Have a unique, descriptive name (kebab-case) +2. Specify its type (research, design, implement, test, review, document) +3. Declare dependencies on prior steps (if any) +4. Map to specific requirement IDs it addresses +5. Include a contract template with tasks and deliverables + +The chain should form a valid DAG (no cycles). Steps can run in parallel if they don't depend on each other. + +Respond with a JSON object in this format: +```json +{{ + "name": "chain-name", + "description": "Brief description of the plan", + "steps": [ + {{ + "name": "step-name", + "step_type": "implement", + "description": "What this step does", + "depends_on": ["prior-step-name"], + "requirement_ids": ["REQ-001"], + "contract_template": {{ + "name": "Contract Name", + "description": "Contract description", + "contract_type": "simple", + "phases": ["plan", "execute"], + "tasks": [ + {{"name": "Task 1", "plan": "Detailed plan for this task"}} + ], + "deliverables": [ + {{"id": "del-1", "name": "Deliverable 1", "priority": "required"}} + ] + }} + }} + ] +}} +``` + +Generate the optimal execution plan now."#, + goal = directive.goal, + requirements = requirements.join("\n"), + criteria = criteria.join("\n"), + constraints = constraints.join("\n"), + ) + } + + /// Parse LLM response into a generated chain. + pub fn parse_plan_response(&self, response: &str) -> Result<GeneratedChain, PlannerError> { + // Extract JSON from response (may be wrapped in markdown code blocks) + let json_str = extract_json_from_response(response)?; + + let chain: GeneratedChain = serde_json::from_str(&json_str) + .map_err(|e| PlannerError::InvalidPlan(format!("JSON parse error: {}", e)))?; + + if chain.steps.is_empty() { + return Err(PlannerError::EmptyPlan); + } + + // Validate the chain + self.validate_chain(&chain)?; + + Ok(chain) + } + + /// Validate a generated chain. + pub fn validate_chain(&self, chain: &GeneratedChain) -> Result<(), PlannerError> { + // Build step name set + let step_names: HashSet<&str> = chain.steps.iter().map(|s| s.name.as_str()).collect(); + + // Check for duplicate names + if step_names.len() != chain.steps.len() { + return Err(PlannerError::InvalidPlan( + "Duplicate step names detected".to_string(), + )); + } + + // Validate dependencies exist + for step in &chain.steps { + for dep in &step.depends_on { + if !step_names.contains(dep.as_str()) { + return Err(PlannerError::InvalidDependency { + step: step.name.clone(), + dependency: dep.clone(), + }); + } + } + } + + // Check for cycles using DFS + self.detect_cycles(chain)?; + + Ok(()) + } + + /// Detect cycles in the chain DAG using DFS. + fn detect_cycles(&self, chain: &GeneratedChain) -> Result<(), PlannerError> { + let mut visited = HashSet::new(); + let mut rec_stack = HashSet::new(); + + // Build adjacency map + let adj: HashMap<&str, Vec<&str>> = chain + .steps + .iter() + .map(|s| (s.name.as_str(), s.depends_on.iter().map(|d| d.as_str()).collect())) + .collect(); + + for step in &chain.steps { + if !visited.contains(step.name.as_str()) { + if self.has_cycle(&step.name, &adj, &mut visited, &mut rec_stack) { + return Err(PlannerError::CycleDetected(step.name.clone())); + } + } + } + + Ok(()) + } + + fn has_cycle<'a>( + &self, + node: &'a str, + adj: &HashMap<&'a str, Vec<&'a str>>, + visited: &mut HashSet<&'a str>, + rec_stack: &mut HashSet<&'a str>, + ) -> bool { + visited.insert(node); + rec_stack.insert(node); + + if let Some(deps) = adj.get(node) { + for &dep in deps { + if !visited.contains(dep) { + if self.has_cycle(dep, adj, visited, rec_stack) { + return true; + } + } else if rec_stack.contains(dep) { + return true; + } + } + } + + rec_stack.remove(node); + false + } + + /// Check that all requirements are covered by at least one step. + pub fn check_requirement_coverage( + &self, + chain: &GeneratedChain, + directive: &Directive, + ) -> Result<(), PlannerError> { + let required_ids: HashSet<String> = directive + .requirements + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.get("id").and_then(|id| id.as_str())) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + + let covered_ids: HashSet<String> = chain + .steps + .iter() + .flat_map(|s| s.requirement_ids.clone()) + .collect(); + + for req_id in required_ids { + if !covered_ids.contains(&req_id) { + return Err(PlannerError::RequirementNotCovered(req_id)); + } + } + + Ok(()) + } + + /// Get topological order of steps. + pub fn topological_sort<'a>( + &self, + chain: &'a GeneratedChain, + ) -> Result<Vec<&'a str>, PlannerError> { + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adj: HashMap<&str, Vec<&str>> = HashMap::new(); + + // Initialize + for step in &chain.steps { + in_degree.entry(step.name.as_str()).or_insert(0); + adj.entry(step.name.as_str()).or_insert_with(Vec::new); + } + + // Build graph (reversed - edges from dependency to dependent) + for step in &chain.steps { + for dep in &step.depends_on { + adj.entry(dep.as_str()) + .or_insert_with(Vec::new) + .push(step.name.as_str()); + *in_degree.entry(step.name.as_str()).or_insert(0) += 1; + } + } + + // Kahn's algorithm + let mut queue: Vec<&str> = in_degree + .iter() + .filter(|&(_, deg)| *deg == 0) + .map(|(&name, _)| name) + .collect(); + + let mut result = Vec::new(); + + while let Some(node) = queue.pop() { + result.push(node); + + if let Some(neighbors) = adj.get(node) { + for &neighbor in neighbors { + let deg = in_degree.get_mut(neighbor).unwrap(); + *deg -= 1; + if *deg == 0 { + queue.push(neighbor); + } + } + } + } + + if result.len() != chain.steps.len() { + return Err(PlannerError::CycleDetected( + "Cycle detected during topological sort".to_string(), + )); + } + + Ok(result) + } + + /// Convert generated steps to AddStepRequest for database insertion. + pub fn steps_to_requests( + &self, + chain: &GeneratedChain, + step_id_map: &HashMap<String, Uuid>, + ) -> Vec<AddStepRequest> { + chain + .steps + .iter() + .map(|step| { + let depends_on: Vec<Uuid> = step + .depends_on + .iter() + .filter_map(|name| step_id_map.get(name)) + .copied() + .collect(); + + let task_plan = step + .contract_template + .as_ref() + .and_then(|t| t.tasks.first()) + .map(|t| t.plan.clone()); + + AddStepRequest { + name: step.name.clone(), + description: Some(step.description.clone()), + step_type: Some(step.step_type.clone()), + contract_type: step.contract_template.as_ref().map(|t| t.contract_type.clone()), + initial_phase: Some("plan".to_string()), + task_plan, + phases: step.contract_template.as_ref().map(|t| t.phases.clone()), + depends_on: Some(depends_on), + parallel_group: None, + requirement_ids: Some(step.requirement_ids.clone()), + acceptance_criteria_ids: None, + verifier_config: None, + editor_x: None, + editor_y: None, + } + }) + .collect() + } + + /// Compute editor positions for steps based on DAG layout. + pub fn compute_editor_positions( + &self, + chain: &GeneratedChain, + ) -> HashMap<String, (f64, f64)> { + let depths = self.get_step_depths(chain); + let mut positions: HashMap<String, (f64, f64)> = HashMap::new(); + + // Group by depth + let mut by_depth: HashMap<usize, Vec<&str>> = HashMap::new(); + for step in &chain.steps { + let depth = depths.get(&step.name).copied().unwrap_or(0); + by_depth.entry(depth).or_default().push(&step.name); + } + + // Compute positions: x based on depth, y based on index within depth + let x_spacing = 250.0; + let y_spacing = 150.0; + + for (depth, steps) in &by_depth { + let x = (*depth as f64) * x_spacing + 100.0; + for (i, name) in steps.iter().enumerate() { + let y = (i as f64) * y_spacing + 100.0; + positions.insert(name.to_string(), (x, y)); + } + } + + positions + } + + /// Get depth of each step in the DAG. + fn get_step_depths(&self, chain: &GeneratedChain) -> HashMap<String, usize> { + let mut depths: HashMap<String, usize> = HashMap::new(); + + // Build dependency map + let deps: HashMap<String, Vec<String>> = chain + .steps + .iter() + .map(|s| (s.name.clone(), s.depends_on.clone())) + .collect(); + + fn compute_depth( + name: &str, + deps: &HashMap<String, Vec<String>>, + depths: &mut HashMap<String, usize>, + ) -> usize { + if let Some(&d) = depths.get(name) { + return d; + } + + let depth = deps + .get(name) + .map(|dep_list| { + dep_list + .iter() + .map(|d| compute_depth(d, deps, depths) + 1) + .max() + .unwrap_or(0) + }) + .unwrap_or(0); + + depths.insert(name.to_string(), depth); + depth + } + + for step in &chain.steps { + compute_depth(&step.name, &deps, &mut depths); + } + + depths + } + + /// Build a replanning prompt that preserves completed steps. + pub fn build_replan_prompt( + &self, + directive: &Directive, + completed_steps: &[ChainStep], + failed_step: Option<&ChainStep>, + reason: &str, + ) -> String { + let completed_summary: Vec<String> = completed_steps + .iter() + .map(|s| format!("- {} ({}): completed", s.name, s.step_type)) + .collect(); + + let failed_summary = failed_step + .map(|s| format!("Failed step: {} - {}", s.name, s.description.as_deref().unwrap_or(""))) + .unwrap_or_default(); + + format!( + r#"You are a software architect replanning an execution chain. + +## Original Goal +{goal} + +## Completed Steps (preserve these) +{completed} + +## Failure Information +{failed} +Reason: {reason} + +## Instructions +Generate a new execution plan that: +1. Preserves all completed work +2. Addresses the failure +3. Continues toward the original goal + +Use the same JSON format as before. Do not include already completed steps."#, + goal = directive.goal, + completed = completed_summary.join("\n"), + failed = failed_summary, + reason = reason, + ) + } +} + +/// Extract JSON from LLM response (handles markdown code blocks). +fn extract_json_from_response(response: &str) -> Result<String, PlannerError> { + // Try to find JSON in code block + if let Some(start) = response.find("```json") { + let json_start = start + 7; + if let Some(end) = response[json_start..].find("```") { + return Ok(response[json_start..json_start + end].trim().to_string()); + } + } + + // Try to find JSON in generic code block + if let Some(start) = response.find("```") { + let block_start = start + 3; + // Skip language identifier if present + let json_start = response[block_start..] + .find('\n') + .map(|i| block_start + i + 1) + .unwrap_or(block_start); + if let Some(end) = response[json_start..].find("```") { + return Ok(response[json_start..json_start + end].trim().to_string()); + } + } + + // Try to parse the whole thing as JSON + if response.trim().starts_with('{') { + return Ok(response.trim().to_string()); + } + + Err(PlannerError::InvalidPlan( + "Could not extract JSON from response".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_chain() -> GeneratedChain { + GeneratedChain { + name: "test-chain".to_string(), + description: "Test chain".to_string(), + steps: vec![ + GeneratedStep { + name: "step-a".to_string(), + step_type: "research".to_string(), + description: "Research step".to_string(), + depends_on: vec![], + requirement_ids: vec!["REQ-001".to_string()], + contract_template: None, + }, + GeneratedStep { + name: "step-b".to_string(), + step_type: "implement".to_string(), + description: "Implementation step".to_string(), + depends_on: vec!["step-a".to_string()], + requirement_ids: vec!["REQ-002".to_string()], + contract_template: None, + }, + GeneratedStep { + name: "step-c".to_string(), + step_type: "test".to_string(), + description: "Test step".to_string(), + depends_on: vec!["step-b".to_string()], + requirement_ids: vec!["REQ-001".to_string()], + contract_template: None, + }, + ], + } + } + + #[test] + fn test_validate_chain_valid() { + let planner = ChainPlanner::new(); + let chain = make_test_chain(); + assert!(planner.validate_chain(&chain).is_ok()); + } + + #[test] + fn test_validate_chain_invalid_dependency() { + let planner = ChainPlanner::new(); + let mut chain = make_test_chain(); + chain.steps[1].depends_on = vec!["nonexistent".to_string()]; + + let result = planner.validate_chain(&chain); + assert!(matches!(result, Err(PlannerError::InvalidDependency { .. }))); + } + + #[test] + fn test_validate_chain_cycle() { + let planner = ChainPlanner::new(); + let chain = GeneratedChain { + name: "cyclic".to_string(), + description: "Has cycle".to_string(), + steps: vec![ + GeneratedStep { + name: "a".to_string(), + step_type: "research".to_string(), + description: "A".to_string(), + depends_on: vec!["c".to_string()], + requirement_ids: vec![], + contract_template: None, + }, + GeneratedStep { + name: "b".to_string(), + step_type: "implement".to_string(), + description: "B".to_string(), + depends_on: vec!["a".to_string()], + requirement_ids: vec![], + contract_template: None, + }, + GeneratedStep { + name: "c".to_string(), + step_type: "test".to_string(), + description: "C".to_string(), + depends_on: vec!["b".to_string()], + requirement_ids: vec![], + contract_template: None, + }, + ], + }; + + let result = planner.validate_chain(&chain); + assert!(matches!(result, Err(PlannerError::CycleDetected(_)))); + } + + #[test] + fn test_topological_sort() { + let planner = ChainPlanner::new(); + let chain = make_test_chain(); + let order = planner.topological_sort(&chain).unwrap(); + + // step-a must come before step-b, step-b before step-c + let pos_a = order.iter().position(|&n| n == "step-a").unwrap(); + let pos_b = order.iter().position(|&n| n == "step-b").unwrap(); + let pos_c = order.iter().position(|&n| n == "step-c").unwrap(); + + assert!(pos_a < pos_b); + assert!(pos_b < pos_c); + } + + #[test] + fn test_extract_json_from_code_block() { + let response = r#" +Here's the plan: + +```json +{"name": "test"} +``` + +That's it! +"#; + let json = extract_json_from_response(response).unwrap(); + assert_eq!(json, r#"{"name": "test"}"#); + } + + #[test] + fn test_extract_json_raw() { + let response = r#"{"name": "test"}"#; + let json = extract_json_from_response(response).unwrap(); + assert_eq!(json, r#"{"name": "test"}"#); + } +} diff --git a/makima/src/orchestration/verifier.rs b/makima/src/orchestration/verifier.rs new file mode 100644 index 0000000..e98da50 --- /dev/null +++ b/makima/src/orchestration/verifier.rs @@ -0,0 +1,806 @@ +//! Verification system for directive step evaluation. +//! +//! Provides tiered verification: programmatic verifiers run first, +//! then LLM evaluation if programmatic checks pass. Composite scoring +//! combines results with configurable weights. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::path::Path; +use thiserror::Error; +use uuid::Uuid; + +/// Confidence level based on composite score and thresholds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfidenceLevel { + /// High confidence (score >= green threshold) + Green, + /// Medium confidence (score >= yellow threshold but < green) + Yellow, + /// Low confidence (score < yellow threshold) + Red, +} + +impl ConfidenceLevel { + /// Compute confidence level from score and thresholds. + pub fn from_score(score: f64, green_threshold: f64, yellow_threshold: f64) -> Self { + if score >= green_threshold { + ConfidenceLevel::Green + } else if score >= yellow_threshold { + ConfidenceLevel::Yellow + } else { + ConfidenceLevel::Red + } + } + + /// Convert to string for database storage. + pub fn as_str(&self) -> &'static str { + match self { + ConfidenceLevel::Green => "green", + ConfidenceLevel::Yellow => "yellow", + ConfidenceLevel::Red => "red", + } + } +} + +impl std::fmt::Display for ConfidenceLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Type of verifier for categorization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerifierType { + /// Run test suite (npm test, cargo test, pytest, etc.) + TestRunner, + /// Run linter (eslint, clippy, ruff, etc.) + Linter, + /// Run type checker (tsc, mypy, etc.) + TypeChecker, + /// Run build command (npm build, cargo build, etc.) + Build, + /// Custom command verifier + Custom, + /// LLM-based semantic evaluation + Llm, +} + +impl VerifierType { + pub fn as_str(&self) -> &'static str { + match self { + VerifierType::TestRunner => "test_runner", + VerifierType::Linter => "linter", + VerifierType::TypeChecker => "type_checker", + VerifierType::Build => "build", + VerifierType::Custom => "custom", + VerifierType::Llm => "llm", + } + } +} + +/// Result of a single verifier run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifierResult { + /// Name of the verifier + pub name: String, + /// Type of verifier + pub verifier_type: VerifierType, + /// Whether the verification passed + pub passed: bool, + /// Score from 0.0 to 1.0 (1.0 = perfect, 0.0 = complete failure) + pub score: f64, + /// Weight for composite scoring (default 1.0 for programmatic, 2.0 for LLM) + pub weight: f64, + /// Whether this verifier is required (failure = automatic red confidence) + pub required: bool, + /// Human-readable output/feedback + pub output: String, + /// Structured details (test counts, lint errors, etc.) + pub details: Option<JsonValue>, + /// Execution time in milliseconds + pub duration_ms: u64, +} + +impl VerifierResult { + /// Create a passed result with full score. + pub fn passed(name: String, verifier_type: VerifierType, output: String) -> Self { + Self { + name, + verifier_type, + passed: true, + score: 1.0, + weight: 1.0, + required: false, + output, + details: None, + duration_ms: 0, + } + } + + /// Create a failed result with zero score. + pub fn failed(name: String, verifier_type: VerifierType, output: String) -> Self { + Self { + name, + verifier_type, + passed: false, + score: 0.0, + weight: 1.0, + required: false, + output, + details: None, + duration_ms: 0, + } + } + + /// Set the weight for this result. + pub fn with_weight(mut self, weight: f64) -> Self { + self.weight = weight; + self + } + + /// Mark this verifier as required. + pub fn as_required(mut self) -> Self { + self.required = true; + self + } + + /// Set the score explicitly. + pub fn with_score(mut self, score: f64) -> Self { + self.score = score.clamp(0.0, 1.0); + self + } + + /// Set structured details. + pub fn with_details(mut self, details: JsonValue) -> Self { + self.details = Some(details); + self + } + + /// Set execution duration. + pub fn with_duration(mut self, duration_ms: u64) -> Self { + self.duration_ms = duration_ms; + self + } +} + +/// Composite evaluation result combining multiple verifier results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvaluationResult { + /// Unique ID for this evaluation + pub id: Uuid, + /// Step ID being evaluated + pub step_id: Uuid, + /// Whether all required verifiers passed + pub passed: bool, + /// Weighted composite score (0.0-1.0) + pub composite_score: f64, + /// Confidence level derived from score + pub confidence_level: ConfidenceLevel, + /// Individual verifier results + pub verifier_results: Vec<VerifierResult>, + /// Summary feedback for the step + pub summary: String, + /// Rework instructions if failed + pub rework_instructions: Option<String>, + /// Total evaluation duration in milliseconds + pub total_duration_ms: u64, +} + +impl EvaluationResult { + /// Create a new evaluation result from verifier results. + pub fn from_verifiers( + step_id: Uuid, + results: Vec<VerifierResult>, + green_threshold: f64, + yellow_threshold: f64, + ) -> Self { + let id = Uuid::new_v4(); + + // Check if any required verifier failed + let any_required_failed = results.iter().any(|r| r.required && !r.passed); + + // Calculate weighted composite score + let (total_weighted_score, total_weight) = + results + .iter() + .fold((0.0, 0.0), |(score_acc, weight_acc), r| { + (score_acc + r.score * r.weight, weight_acc + r.weight) + }); + + let composite_score = if total_weight > 0.0 { + total_weighted_score / total_weight + } else { + 0.0 + }; + + // Override confidence to red if any required verifier failed + let confidence_level = if any_required_failed { + ConfidenceLevel::Red + } else { + ConfidenceLevel::from_score(composite_score, green_threshold, yellow_threshold) + }; + + let passed = !any_required_failed && confidence_level != ConfidenceLevel::Red; + + // Generate summary + let passed_count = results.iter().filter(|r| r.passed).count(); + let total_count = results.len(); + let summary = format!( + "{}/{} verifiers passed, composite score: {:.2}, confidence: {}", + passed_count, total_count, composite_score, confidence_level + ); + + // Generate rework instructions if failed + let rework_instructions = if !passed { + let failed_verifiers: Vec<&str> = results + .iter() + .filter(|r| !r.passed) + .map(|r| r.name.as_str()) + .collect(); + Some(format!( + "Fix issues identified by: {}", + failed_verifiers.join(", ") + )) + } else { + None + }; + + let total_duration_ms = results.iter().map(|r| r.duration_ms).sum(); + + Self { + id, + step_id, + passed, + composite_score, + confidence_level, + verifier_results: results, + summary, + rework_instructions, + total_duration_ms, + } + } +} + +/// Error type for verification operations. +#[derive(Error, Debug)] +pub enum VerifierError { + #[error("Command execution failed: {0}")] + CommandFailed(String), + + #[error("Command timed out after {0}ms")] + Timeout(u64), + + #[error("Working directory not found: {0}")] + WorkingDirectoryNotFound(String), + + #[error("Verifier not configured: {0}")] + NotConfigured(String), + + #[error("Parse error: {0}")] + ParseError(String), + + #[error("LLM error: {0}")] + LlmError(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Verifier trait for pluggable verification implementations. +#[async_trait] +pub trait Verifier: Send + Sync { + /// Get the name of this verifier. + fn name(&self) -> &str; + + /// Get the type of this verifier. + fn verifier_type(&self) -> VerifierType; + + /// Check if this verifier is applicable to the given repository. + async fn is_applicable(&self, repo_path: &Path) -> bool; + + /// Run verification and return result. + async fn verify(&self, repo_path: &Path, context: &VerificationContext) + -> Result<VerifierResult, VerifierError>; +} + +/// Context provided to verifiers during execution. +#[derive(Debug, Clone)] +pub struct VerificationContext { + /// Step ID being verified + pub step_id: Uuid, + /// Contract ID if step has been instantiated + pub contract_id: Option<Uuid>, + /// Files that were modified in this step + pub modified_files: Vec<String>, + /// Step description for LLM context + pub step_description: String, + /// Acceptance criteria for LLM evaluation + pub acceptance_criteria: Vec<String>, + /// Additional context from directive + pub directive_context: String, +} + +/// Command-based verifier for running shell commands. +pub struct CommandVerifier { + name: String, + verifier_type: VerifierType, + command: String, + #[allow(dead_code)] + working_dir: Option<String>, + #[allow(dead_code)] + timeout_ms: u64, + required: bool, + /// Files/patterns that indicate this verifier is applicable + applicable_patterns: Vec<String>, +} + +impl CommandVerifier { + /// Create a new command verifier. + pub fn new( + name: impl Into<String>, + verifier_type: VerifierType, + command: impl Into<String>, + ) -> Self { + Self { + name: name.into(), + verifier_type, + command: command.into(), + working_dir: None, + timeout_ms: 300_000, // 5 minute default + required: false, + applicable_patterns: Vec::new(), + } + } + + /// Set the working directory. + #[allow(dead_code)] + pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self { + self.working_dir = Some(dir.into()); + self + } + + /// Set the timeout in milliseconds. + #[allow(dead_code)] + pub fn with_timeout(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = timeout_ms; + self + } + + /// Mark as required verifier. + pub fn as_required(mut self) -> Self { + self.required = true; + self + } + + /// Add applicability patterns (files that must exist). + pub fn with_patterns(mut self, patterns: Vec<String>) -> Self { + self.applicable_patterns = patterns; + self + } +} + +#[async_trait] +impl Verifier for CommandVerifier { + fn name(&self) -> &str { + &self.name + } + + fn verifier_type(&self) -> VerifierType { + self.verifier_type.clone() + } + + async fn is_applicable(&self, repo_path: &Path) -> bool { + if self.applicable_patterns.is_empty() { + return true; + } + + for pattern in &self.applicable_patterns { + let check_path = repo_path.join(pattern); + if check_path.exists() { + return true; + } + } + false + } + + async fn verify( + &self, + repo_path: &Path, + _context: &VerificationContext, + ) -> Result<VerifierResult, VerifierError> { + let start = std::time::Instant::now(); + + let work_dir = self + .working_dir + .as_ref() + .map(|d| repo_path.join(d)) + .unwrap_or_else(|| repo_path.to_path_buf()); + + if !work_dir.exists() { + return Err(VerifierError::WorkingDirectoryNotFound( + work_dir.display().to_string(), + )); + } + + // Parse command into program and args + let parts: Vec<&str> = self.command.split_whitespace().collect(); + if parts.is_empty() { + return Err(VerifierError::CommandFailed( + "Empty command".to_string(), + )); + } + + let program = parts[0]; + let args = &parts[1..]; + + // Execute command + let output = tokio::process::Command::new(program) + .args(args) + .current_dir(&work_dir) + .output() + .await?; + + let duration_ms = start.elapsed().as_millis() as u64; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined_output = format!("{}\n{}", stdout, stderr); + + let passed = output.status.success(); + let score = if passed { 1.0 } else { 0.0 }; + + let mut result = VerifierResult { + name: self.name.clone(), + verifier_type: self.verifier_type.clone(), + passed, + score, + weight: 1.0, + required: self.required, + output: combined_output, + details: Some(serde_json::json!({ + "exit_code": output.status.code(), + "command": self.command, + "working_dir": work_dir.display().to_string(), + })), + duration_ms, + }; + + // Try to extract more detailed scoring from output + result = self.enhance_result(result, &stdout); + + Ok(result) + } +} + +impl CommandVerifier { + /// Enhance result with parsed details from output. + fn enhance_result(&self, mut result: VerifierResult, stdout: &str) -> VerifierResult { + match self.verifier_type { + VerifierType::TestRunner => { + // Try to parse test counts from common formats + if let Some((passed, failed, total)) = parse_test_output(stdout) { + result.details = Some(serde_json::json!({ + "tests_passed": passed, + "tests_failed": failed, + "tests_total": total, + "command": self.command, + })); + if total > 0 { + result.score = passed as f64 / total as f64; + } + } + } + VerifierType::Linter => { + // Try to parse lint error counts + if let Some(error_count) = parse_lint_output(stdout) { + result.details = Some(serde_json::json!({ + "errors": error_count, + "command": self.command, + })); + // Score decreases with more errors (up to 10 errors = 0) + result.score = (1.0 - (error_count as f64 / 10.0)).max(0.0); + } + } + _ => {} + } + result + } +} + +/// Parse test output for common formats (Jest, pytest, cargo test). +fn parse_test_output(output: &str) -> Option<(u32, u32, u32)> { + // Jest format: "Tests: X passed, Y failed, Z total" + if let Some(caps) = regex::Regex::new(r"Tests:\s*(\d+)\s*passed,\s*(\d+)\s*failed,\s*(\d+)\s*total") + .ok()? + .captures(output) + { + let passed: u32 = caps.get(1)?.as_str().parse().ok()?; + let failed: u32 = caps.get(2)?.as_str().parse().ok()?; + let total: u32 = caps.get(3)?.as_str().parse().ok()?; + return Some((passed, failed, total)); + } + + // pytest format: "X passed, Y failed" + if let Some(caps) = regex::Regex::new(r"(\d+)\s*passed(?:,\s*(\d+)\s*failed)?") + .ok()? + .captures(output) + { + let passed: u32 = caps.get(1)?.as_str().parse().ok()?; + let failed: u32 = caps.get(2).map(|m| m.as_str().parse().ok()).flatten().unwrap_or(0); + let total = passed + failed; + return Some((passed, failed, total)); + } + + // cargo test format: "test result: ok. X passed; Y failed;" + if let Some(caps) = regex::Regex::new(r"test result:.*?(\d+)\s*passed;\s*(\d+)\s*failed") + .ok()? + .captures(output) + { + let passed: u32 = caps.get(1)?.as_str().parse().ok()?; + let failed: u32 = caps.get(2)?.as_str().parse().ok()?; + let total = passed + failed; + return Some((passed, failed, total)); + } + + None +} + +/// Parse lint output for error counts. +fn parse_lint_output(output: &str) -> Option<u32> { + // ESLint format: "X problems (Y errors, Z warnings)" + if let Some(caps) = regex::Regex::new(r"(\d+)\s*problems?\s*\((\d+)\s*errors?") + .ok()? + .captures(output) + { + return caps.get(2)?.as_str().parse().ok(); + } + + // Clippy format: "warning: X warnings emitted" + if let Some(caps) = regex::Regex::new(r"warning:\s*(\d+)\s*warnings?\s*emitted") + .ok()? + .captures(output) + { + return caps.get(1)?.as_str().parse().ok(); + } + + None +} + +/// Auto-detect applicable verifiers for a repository. +pub async fn auto_detect_verifiers(repo_path: &Path) -> Vec<Box<dyn Verifier>> { + let mut verifiers: Vec<Box<dyn Verifier>> = Vec::new(); + + // Check for package.json (Node.js) + let package_json = repo_path.join("package.json"); + if package_json.exists() { + if let Ok(content) = tokio::fs::read_to_string(&package_json).await { + if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) { + if let Some(scripts) = pkg.get("scripts").and_then(|s| s.as_object()) { + // Test runner + if scripts.contains_key("test") { + verifiers.push(Box::new( + CommandVerifier::new("npm-test", VerifierType::TestRunner, "npm test") + .with_patterns(vec!["package.json".to_string()]) + .as_required(), + )); + } + + // Linter + if scripts.contains_key("lint") { + verifiers.push(Box::new( + CommandVerifier::new("npm-lint", VerifierType::Linter, "npm run lint") + .with_patterns(vec!["package.json".to_string()]), + )); + } + + // Build + if scripts.contains_key("build") { + verifiers.push(Box::new( + CommandVerifier::new("npm-build", VerifierType::Build, "npm run build") + .with_patterns(vec!["package.json".to_string()]) + .as_required(), + )); + } + + // Type check (for TypeScript projects) + if scripts.contains_key("typecheck") || scripts.contains_key("type-check") { + let cmd = if scripts.contains_key("typecheck") { + "npm run typecheck" + } else { + "npm run type-check" + }; + verifiers.push(Box::new( + CommandVerifier::new("npm-typecheck", VerifierType::TypeChecker, cmd) + .with_patterns(vec!["tsconfig.json".to_string()]), + )); + } + } + } + } + } + + // Check for Cargo.toml (Rust) + let cargo_toml = repo_path.join("Cargo.toml"); + if cargo_toml.exists() { + verifiers.push(Box::new( + CommandVerifier::new("cargo-test", VerifierType::TestRunner, "cargo test") + .with_patterns(vec!["Cargo.toml".to_string()]) + .as_required(), + )); + + verifiers.push(Box::new( + CommandVerifier::new("cargo-clippy", VerifierType::Linter, "cargo clippy -- -D warnings") + .with_patterns(vec!["Cargo.toml".to_string()]), + )); + + verifiers.push(Box::new( + CommandVerifier::new("cargo-build", VerifierType::Build, "cargo build") + .with_patterns(vec!["Cargo.toml".to_string()]) + .as_required(), + )); + } + + // Check for pyproject.toml or setup.py (Python) + let pyproject = repo_path.join("pyproject.toml"); + let setup_py = repo_path.join("setup.py"); + if pyproject.exists() || setup_py.exists() { + verifiers.push(Box::new( + CommandVerifier::new("pytest", VerifierType::TestRunner, "pytest") + .with_patterns(vec![ + "pyproject.toml".to_string(), + "setup.py".to_string(), + ]) + .as_required(), + )); + + verifiers.push(Box::new( + CommandVerifier::new("ruff", VerifierType::Linter, "ruff check .") + .with_patterns(vec!["pyproject.toml".to_string()]), + )); + } + + verifiers +} + +/// Composite evaluator that runs multiple verifiers and combines results. +pub struct CompositeEvaluator { + verifiers: Vec<Box<dyn Verifier>>, + green_threshold: f64, + yellow_threshold: f64, +} + +impl CompositeEvaluator { + /// Create a new composite evaluator with default thresholds. + pub fn new(verifiers: Vec<Box<dyn Verifier>>) -> Self { + Self { + verifiers, + green_threshold: 0.8, + yellow_threshold: 0.5, + } + } + + /// Set confidence thresholds. + pub fn with_thresholds(mut self, green: f64, yellow: f64) -> Self { + self.green_threshold = green; + self.yellow_threshold = yellow; + self + } + + /// Add a verifier. + pub fn add_verifier(mut self, verifier: Box<dyn Verifier>) -> Self { + self.verifiers.push(verifier); + self + } + + /// Run all applicable verifiers and return composite result. + pub async fn evaluate( + &self, + repo_path: &Path, + context: &VerificationContext, + ) -> EvaluationResult { + let mut results = Vec::new(); + + for verifier in &self.verifiers { + if !verifier.is_applicable(repo_path).await { + continue; + } + + match verifier.verify(repo_path, context).await { + Ok(result) => results.push(result), + Err(e) => { + // Convert error to failed result + results.push(VerifierResult::failed( + verifier.name().to_string(), + verifier.verifier_type(), + format!("Verifier error: {}", e), + )); + } + } + } + + EvaluationResult::from_verifiers( + context.step_id, + results, + self.green_threshold, + self.yellow_threshold, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confidence_level_from_score() { + assert_eq!( + ConfidenceLevel::from_score(0.9, 0.8, 0.5), + ConfidenceLevel::Green + ); + assert_eq!( + ConfidenceLevel::from_score(0.8, 0.8, 0.5), + ConfidenceLevel::Green + ); + assert_eq!( + ConfidenceLevel::from_score(0.6, 0.8, 0.5), + ConfidenceLevel::Yellow + ); + assert_eq!( + ConfidenceLevel::from_score(0.5, 0.8, 0.5), + ConfidenceLevel::Yellow + ); + assert_eq!( + ConfidenceLevel::from_score(0.4, 0.8, 0.5), + ConfidenceLevel::Red + ); + } + + #[test] + fn test_evaluation_result_composite_score() { + let results = vec![ + VerifierResult::passed("test1".into(), VerifierType::TestRunner, "OK".into()) + .with_weight(1.0), + VerifierResult::failed("test2".into(), VerifierType::Linter, "Failed".into()) + .with_weight(1.0), + ]; + + let eval = EvaluationResult::from_verifiers(Uuid::new_v4(), results, 0.8, 0.5); + assert!((eval.composite_score - 0.5).abs() < 0.001); + assert_eq!(eval.confidence_level, ConfidenceLevel::Yellow); + } + + #[test] + fn test_required_verifier_override() { + let results = vec![ + VerifierResult::passed("test1".into(), VerifierType::TestRunner, "OK".into()), + VerifierResult::failed("build".into(), VerifierType::Build, "Failed".into()) + .as_required(), + ]; + + let eval = EvaluationResult::from_verifiers(Uuid::new_v4(), results, 0.8, 0.5); + // Even though composite score is 0.5, required failure overrides to red + assert_eq!(eval.confidence_level, ConfidenceLevel::Red); + assert!(!eval.passed); + } + + #[test] + fn test_parse_test_output_jest() { + let output = "Tests: 10 passed, 2 failed, 12 total"; + let (passed, failed, total) = parse_test_output(output).unwrap(); + assert_eq!(passed, 10); + assert_eq!(failed, 2); + assert_eq!(total, 12); + } + + #[test] + fn test_parse_test_output_cargo() { + let output = "test result: ok. 25 passed; 0 failed;"; + let (passed, failed, total) = parse_test_output(output).unwrap(); + assert_eq!(passed, 25); + assert_eq!(failed, 0); + assert_eq!(total, 25); + } +} diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 06b3a7c..8153093 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -17,8 +17,6 @@ use uuid::Uuid; use crate::db::{ models::{ ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest, - AddContractDefinitionRequest, UpdateContractDefinitionRequest, CreateChainRequest, - CreateChainDirectiveRequest, CreateContractEvaluationRequest, }, repository, }; @@ -2767,1211 +2765,26 @@ async fn handle_contract_request( } } - // Chain directive tools - for directive contracts to create and manage chains - ContractToolRequest::CreateChainFromDirective { name, description } => { - // First, get the current contract to verify it's a directive contract - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - // Check if contract already has a spawned chain - if contract.spawned_chain_id.is_some() { - return ContractRequestResult { - success: false, - message: "This contract already has a chain associated with it".to_string(), - data: Some(json!({ "existing_chain_id": contract.spawned_chain_id })), - }; - } - - // Create the chain - let chain_req = CreateChainRequest { - name: name.clone(), - description: description.clone(), - repositories: None, - loop_enabled: None, - loop_max_iterations: None, - loop_progress_check: None, - contracts: None, - }; - - let chain = match repository::create_chain_for_owner(pool, owner_id, chain_req).await { - Ok(c) => c, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to create chain: {}", e), - data: None, - }, - }; - - // Link the chain to this directive contract - if let Err(e) = sqlx::query( - r#" - UPDATE chains SET directive_contract_id = $2, evaluation_enabled = true WHERE id = $1; - UPDATE contracts SET spawned_chain_id = $1, is_chain_directive = true WHERE id = $2; - "#, - ) - .bind(chain.id) - .bind(contract_id) - .execute(pool) - .await { - return ContractRequestResult { - success: false, - message: format!("Failed to link chain to contract: {}", e), - data: None, - }; - } - - // Create empty directive for the chain - let directive_req = CreateChainDirectiveRequest { - requirements: Some(vec![]), - acceptance_criteria: Some(vec![]), - constraints: Some(vec![]), - external_dependencies: Some(vec![]), - source_type: Some("llm_generated".to_string()), - }; - - if let Err(e) = repository::create_chain_directive(pool, chain.id, directive_req).await { - return ContractRequestResult { - success: false, - message: format!("Failed to create directive: {}", e), - data: None, - }; - } - - ContractRequestResult { - success: true, - message: format!("Created chain '{}' linked to this directive contract", name), - data: Some(json!({ - "chain_id": chain.id, - "chain_name": name, - "description": description - })), - } - } - - ContractToolRequest::AddChainContract { name, description, contract_type, depends_on, requirement_ids } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain. Use create_chain_from_directive first.".to_string(), - data: None, - }, - }; - - // Check for duplicate names - let existing_defs = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - if existing_defs.iter().any(|d| d.name == name) { - return ContractRequestResult { - success: false, - message: format!("A contract definition with name '{}' already exists", name), - data: None, - }; - } - - // Create the contract definition - let def_req = AddContractDefinitionRequest { - name: name.clone(), - description, - contract_type: contract_type.unwrap_or_else(|| "implementation".to_string()), - initial_phase: Some("research".to_string()), - depends_on, - tasks: None, - deliverables: None, - validation: None, - editor_x: None, - editor_y: None, - }; - - let definition = match repository::create_chain_contract_definition(pool, chain_id, def_req).await { - Ok(d) => d, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to create contract definition: {}", e), - data: None, - }, - }; - - // Update requirement_ids if provided - if let Some(req_ids) = requirement_ids { - if !req_ids.is_empty() { - if let Err(e) = sqlx::query( - "UPDATE chain_contract_definitions SET requirement_ids = $2 WHERE id = $1" - ) - .bind(definition.id) - .bind(&req_ids) - .execute(pool) - .await { - tracing::warn!("Failed to set requirement_ids: {}", e); - } - } - } - - ContractRequestResult { - success: true, - message: format!("Added contract '{}' to chain", name), - data: Some(json!({ - "definition_id": definition.id, - "name": name, - "order_index": definition.order_index - })), - } - } - - ContractToolRequest::SetChainDependencies { contract_name, depends_on } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Find the definition by name - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let definition = match definitions.iter().find(|d| d.name == contract_name) { - Some(d) => d, - None => return ContractRequestResult { - success: false, - message: format!("No contract definition named '{}' found", contract_name), - data: None, - }, - }; - - // Validate that all dependencies exist - for dep_name in &depends_on { - if !definitions.iter().any(|d| &d.name == dep_name) { - return ContractRequestResult { - success: false, - message: format!("Dependency '{}' does not exist", dep_name), - data: None, - }; - } - } - - // Check for circular dependencies (simple check) - if depends_on.contains(&contract_name) { - return ContractRequestResult { - success: false, - message: "A contract cannot depend on itself".to_string(), - data: None, - }; - } - - // Update dependencies - let update_req = UpdateContractDefinitionRequest { - name: None, - description: None, - contract_type: None, - initial_phase: None, - depends_on: Some(depends_on.clone()), - tasks: None, - deliverables: None, - validation: None, - editor_x: None, - editor_y: None, - }; - - match repository::update_chain_contract_definition(pool, definition.id, update_req).await { - Ok(_) => ContractRequestResult { - success: true, - message: format!("Updated dependencies for '{}'", contract_name), - data: Some(json!({ - "contract_name": contract_name, - "depends_on": depends_on - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to update dependencies: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ModifyChainContract { name, new_name, description, add_requirement_ids, remove_requirement_ids } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Find the definition by name - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let definition = match definitions.iter().find(|d| d.name == name) { - Some(d) => d.clone(), - None => return ContractRequestResult { - success: false, - message: format!("No contract definition named '{}' found", name), - data: None, - }, - }; - - // Check if new name would conflict - if let Some(ref nn) = new_name { - if nn != &name && definitions.iter().any(|d| &d.name == nn) { - return ContractRequestResult { - success: false, - message: format!("A contract definition named '{}' already exists", nn), - data: None, - }; - } - } - - // Update the definition - let update_req = UpdateContractDefinitionRequest { - name: new_name.clone(), - description, - contract_type: None, - initial_phase: None, - depends_on: None, - tasks: None, - deliverables: None, - validation: None, - editor_x: None, - editor_y: None, - }; - - if let Err(e) = repository::update_chain_contract_definition(pool, definition.id, update_req).await { - return ContractRequestResult { - success: false, - message: format!("Failed to update definition: {}", e), - data: None, - }; - } - - // Handle requirement_ids modifications - let mut current_req_ids: Vec<String> = definition.requirement_ids.clone(); - if let Some(add_ids) = add_requirement_ids { - for id in add_ids { - if !current_req_ids.contains(&id) { - current_req_ids.push(id); - } - } - } - if let Some(remove_ids) = remove_requirement_ids { - current_req_ids.retain(|id| !remove_ids.contains(id)); - } - - if current_req_ids != definition.requirement_ids { - if let Err(e) = sqlx::query( - "UPDATE chain_contract_definitions SET requirement_ids = $2 WHERE id = $1" - ) - .bind(definition.id) - .bind(¤t_req_ids) - .execute(pool) - .await { - tracing::warn!("Failed to update requirement_ids: {}", e); - } - } - - ContractRequestResult { - success: true, - message: format!("Modified contract definition '{}'", new_name.as_ref().unwrap_or(&name)), - data: Some(json!({ - "definition_id": definition.id, - "name": new_name.as_ref().unwrap_or(&name), - "requirement_ids": current_req_ids - })), - } - } - - ContractToolRequest::RemoveChainContract { name } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Find the definition by name - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let definition = match definitions.iter().find(|d| d.name == name) { - Some(d) => d, - None => return ContractRequestResult { - success: false, - message: format!("No contract definition named '{}' found", name), - data: None, - }, - }; - - // Check if other definitions depend on this one - let dependents: Vec<&str> = definitions.iter() - .filter(|d| d.depends_on_names.contains(&name)) - .map(|d| d.name.as_str()) - .collect(); - - if !dependents.is_empty() { - return ContractRequestResult { - success: false, - message: format!("Cannot remove '{}': other contracts depend on it: {}", name, dependents.join(", ")), - data: None, - }; - } - - // Delete the definition - match repository::delete_chain_contract_definition(pool, definition.id).await { - Ok(true) => ContractRequestResult { - success: true, - message: format!("Removed contract definition '{}'", name), - data: Some(json!({ "removed": name })), - }, - Ok(false) => ContractRequestResult { - success: false, - message: format!("Failed to remove '{}': not found", name), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to remove definition: {}", e), - data: None, - }, - } - } - - ContractToolRequest::PreviewChainDag => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get chain details and definitions - let chain = match repository::get_chain_for_owner(pool, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Chain not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - // Build DAG representation - let nodes: Vec<serde_json::Value> = definitions.iter().map(|d| { - json!({ - "name": d.name, - "description": d.description, - "contract_type": d.contract_type, - "depends_on": d.depends_on_names, - "requirement_ids": d.requirement_ids - }) - }).collect(); - - // Build ASCII DAG representation - let mut ascii_dag = String::new(); - ascii_dag.push_str(&format!("Chain: {} ({})\n", chain.name, chain.status)); - ascii_dag.push_str(&format!("Contracts: {}\n\n", definitions.len())); - - // Find root nodes (no dependencies) - let roots: Vec<&str> = definitions.iter() - .filter(|d| d.depends_on_names.is_empty()) - .map(|d| d.name.as_str()) - .collect(); - - ascii_dag.push_str("Root contracts (no dependencies):\n"); - for root in &roots { - ascii_dag.push_str(&format!(" [{}]\n", root)); - } - - ascii_dag.push_str("\nDependency relationships:\n"); - for def in &definitions { - if !def.depends_on_names.is_empty() { - ascii_dag.push_str(&format!(" {} <- {}\n", def.name, def.depends_on_names.join(", "))); - } - } - - ContractRequestResult { - success: true, - message: format!("Chain DAG preview with {} contracts", definitions.len()), - data: Some(json!({ - "chain_id": chain_id, - "chain_name": chain.name, - "chain_status": chain.status, - "contract_count": definitions.len(), - "nodes": nodes, - "ascii_dag": ascii_dag - })), - } - } - - ContractToolRequest::ValidateChainDirective => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let mut errors: Vec<String> = Vec::new(); - let mut warnings: Vec<String> = Vec::new(); - - // Check for empty chain - if definitions.is_empty() { - errors.push("Chain has no contract definitions".to_string()); - } - - // Check for circular dependencies - let def_names: std::collections::HashSet<String> = definitions.iter().map(|d| d.name.clone()).collect(); - for def in &definitions { - for dep in &def.depends_on_names { - if !def_names.contains(dep) { - errors.push(format!("'{}' depends on non-existent contract '{}'", def.name, dep)); - } - } - } - - // Simple cycle detection using DFS - fn has_cycle( - name: &str, - definitions: &[crate::db::models::ChainContractDefinition], - visited: &mut std::collections::HashSet<String>, - rec_stack: &mut std::collections::HashSet<String>, - ) -> Option<String> { - visited.insert(name.to_string()); - rec_stack.insert(name.to_string()); - - if let Some(def) = definitions.iter().find(|d| d.name == name) { - for dep in &def.depends_on_names { - if !visited.contains(dep) { - if let Some(cycle) = has_cycle(dep, definitions, visited, rec_stack) { - return Some(cycle); - } - } else if rec_stack.contains(dep) { - return Some(format!("{} -> {}", name, dep)); - } - } - } - - rec_stack.remove(name); - None - } - - let mut visited = std::collections::HashSet::new(); - for def in &definitions { - if !visited.contains(&def.name) { - let mut rec_stack = std::collections::HashSet::new(); - if let Some(cycle) = has_cycle(&def.name, &definitions, &mut visited, &mut rec_stack) { - errors.push(format!("Circular dependency detected: {}", cycle)); - break; - } - } - } - - // Check for orphan contracts (no one depends on them and they're not root) - let roots: std::collections::HashSet<&str> = definitions.iter() - .filter(|d| d.depends_on_names.is_empty()) - .map(|d| d.name.as_str()) - .collect(); - - let depended_on: std::collections::HashSet<&str> = definitions.iter() - .flat_map(|d| d.depends_on_names.iter().map(|s| s.as_str())) - .collect(); - - for def in &definitions { - if !roots.contains(def.name.as_str()) && !depended_on.contains(def.name.as_str()) { - warnings.push(format!("'{}' has dependencies but nothing depends on it (orphan leaf)", def.name)); - } - } - - // Get directive to check requirement coverage - if let Ok(Some(directive)) = repository::get_chain_directive(pool, chain_id).await { - let requirements: Vec<crate::db::models::DirectiveRequirement> = - serde_json::from_value(directive.requirements.clone()).unwrap_or_default(); - - let covered: std::collections::HashSet<&str> = definitions.iter() - .flat_map(|d| d.requirement_ids.iter().map(|s| s.as_str())) - .collect(); - - for req in &requirements { - if !covered.contains(req.id.as_str()) { - warnings.push(format!("Requirement '{}' ({}) is not covered by any contract", req.id, req.title)); - } - } - } - - let is_valid = errors.is_empty(); - - ContractRequestResult { - success: is_valid, - message: if is_valid { - format!("Chain is valid with {} contracts", definitions.len()) - } else { - format!("Chain validation failed with {} errors", errors.len()) - }, - data: Some(json!({ - "valid": is_valid, - "contract_count": definitions.len(), - "errors": errors, - "warnings": warnings - })), - } - } - - ContractToolRequest::FinalizeChainDirective { auto_start } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get chain - let chain = match repository::get_chain_for_owner(pool, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Chain not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - if chain.status != "pending" { - return ContractRequestResult { - success: false, - message: format!("Chain is already {} - cannot finalize", chain.status), - data: None, - }; - } - - // Update chain status - let new_status = if auto_start { "active" } else { "pending" }; - if let Err(e) = sqlx::query("UPDATE chains SET status = $2 WHERE id = $1") - .bind(chain_id) - .bind(new_status) - .execute(pool) - .await { - return ContractRequestResult { - success: false, - message: format!("Failed to update chain status: {}", e), - data: None, - }; - } - - // If auto_start, trigger chain progression to create root contracts - if auto_start { - match repository::progress_chain(pool, chain_id, owner_id).await { - Ok(result) => { - ContractRequestResult { - success: true, - message: format!("Chain finalized and started. Created {} root contracts.", result.contracts_created.len()), - data: Some(json!({ - "chain_id": chain_id, - "status": "active", - "contracts_created": result.contracts_created, - "chain_completed": result.chain_completed - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Chain finalized but failed to start: {}", e), - data: Some(json!({ "chain_id": chain_id, "status": "active" })), - }, - } - } else { - ContractRequestResult { - success: true, - message: "Chain finalized but not started. Call finalize_chain_directive with auto_start=true to start.".to_string(), - data: Some(json!({ - "chain_id": chain_id, - "status": "pending" - })), - } - } - } - - ContractToolRequest::GetChainStatus => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get chain details - let chain = match repository::get_chain_for_owner(pool, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Chain not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - // Get definitions - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - // Get instantiated contracts - let chain_contracts = match repository::list_chain_contracts(pool, chain_id).await { - Ok(cc) => cc, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list chain contracts: {}", e), - data: None, - }, - }; - - // Build status map - let contract_statuses: Vec<serde_json::Value> = chain_contracts.iter().map(|cc| { - json!({ - "name": cc.contract_name, - "contract_id": cc.contract_id, - "status": cc.contract_status, - "phase": cc.contract_phase, - "evaluation_status": cc.evaluation_status, - "evaluation_retry_count": cc.evaluation_retry_count - }) - }).collect(); - - let completed = chain_contracts.iter().filter(|cc| cc.contract_status == "completed").count(); - let active = chain_contracts.iter().filter(|cc| cc.contract_status == "active").count(); - let pending = definitions.len() - chain_contracts.len(); - - ContractRequestResult { - success: true, - message: format!("Chain '{}': {} completed, {} active, {} pending", - chain.name, completed, active, pending), - data: Some(json!({ - "chain_id": chain_id, - "chain_name": chain.name, - "chain_status": chain.status, - "total_definitions": definitions.len(), - "instantiated": chain_contracts.len(), - "completed": completed, - "active": active, - "pending": pending, - "contracts": contract_statuses - })), - } - } - - ContractToolRequest::GetUncoveredRequirements => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get directive - let directive = match repository::get_chain_directive(pool, chain_id).await { - Ok(Some(d)) => d, - Ok(None) => return ContractRequestResult { - success: true, - message: "No directive found for this chain".to_string(), - data: Some(json!({ "uncovered": [], "total_requirements": 0 })), - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - // Get definitions - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - // Parse requirements - let requirements: Vec<crate::db::models::DirectiveRequirement> = - serde_json::from_value(directive.requirements.clone()).unwrap_or_default(); - - // Find covered requirement IDs - let covered: std::collections::HashSet<String> = definitions.iter() - .flat_map(|d| d.requirement_ids.iter().cloned()) - .collect(); - - // Find uncovered requirements - let uncovered: Vec<serde_json::Value> = requirements.iter() - .filter(|r| !covered.contains(&r.id)) - .map(|r| json!({ - "id": r.id, - "title": r.title, - "priority": r.priority - })) - .collect(); - - ContractRequestResult { - success: true, - message: format!("{} of {} requirements are uncovered", uncovered.len(), requirements.len()), - data: Some(json!({ - "uncovered": uncovered, - "uncovered_count": uncovered.len(), - "total_requirements": requirements.len(), - "coverage_percent": if requirements.is_empty() { 100.0 } else { - ((requirements.len() - uncovered.len()) as f64 / requirements.len() as f64 * 100.0).round() - } - })), - } - } - - ContractToolRequest::EvaluateContractCompletion { contract_id: target_contract_id, passed, feedback, rework_instructions } => { - // Get the directive contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Verify the target contract is part of this chain - let chain_contract = match repository::get_chain_contract_by_contract_id(pool, target_contract_id).await { - Ok(Some(cc)) => cc, - Ok(None) => return ContractRequestResult { - success: false, - message: format!("Contract {} is not part of a chain", target_contract_id), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - if chain_contract.chain_id != chain_id { - return ContractRequestResult { - success: false, - message: "Contract is not part of this directive's chain".to_string(), - data: None, - }; - } - - // Create evaluation record - let eval_req = CreateContractEvaluationRequest { - contract_id: target_contract_id, - chain_id: Some(chain_id), - chain_contract_id: Some(chain_contract.id), - evaluator_model: Some("directive_contract".to_string()), - passed, - overall_score: if passed { Some(1.0) } else { Some(0.0) }, - criteria_results: vec![], - summary_feedback: feedback.clone(), - rework_instructions: rework_instructions.clone(), - }; - - let evaluation = match repository::create_contract_evaluation(pool, eval_req).await { - Ok(e) => e, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to create evaluation: {}", e), - data: None, - }, - }; - - // Update chain contract evaluation status - let new_status = if passed { "passed" } else { "failed" }; - if let Err(e) = repository::update_chain_contract_evaluation_status( - pool, - chain_contract.id, - new_status, - Some(evaluation.id), - None, // No rework feedback for passed/failed status - ).await { - tracing::warn!("Failed to update chain contract evaluation status: {}", e); - } - - if passed { - // Progress the chain to create downstream contracts - match repository::progress_chain(pool, chain_id, owner_id).await { - Ok(result) => ContractRequestResult { - success: true, - message: format!("Evaluation passed. Created {} downstream contracts.", result.contracts_created.len()), - data: Some(json!({ - "evaluation_id": evaluation.id, - "passed": true, - "contracts_created": result.contracts_created, - "chain_completed": result.chain_completed - })), - }, - Err(e) => ContractRequestResult { - success: true, - message: format!("Evaluation passed but failed to progress chain: {}", e), - data: Some(json!({ - "evaluation_id": evaluation.id, - "passed": true - })), - }, - } - } else { - // Mark contract for rework - if let Err(e) = sqlx::query( - r#" - UPDATE chain_contracts SET evaluation_status = 'rework', rework_feedback = $2 WHERE id = $1; - UPDATE contracts SET status = 'active' WHERE id = (SELECT contract_id FROM chain_contracts WHERE id = $1); - "# - ) - .bind(chain_contract.id) - .bind(&rework_instructions) - .execute(pool) - .await { - tracing::warn!("Failed to mark contract for rework: {}", e); - } - - ContractRequestResult { - success: true, - message: format!("Evaluation failed. Contract marked for rework."), - data: Some(json!({ - "evaluation_id": evaluation.id, - "passed": false, - "rework_instructions": rework_instructions, - "retry_count": chain_contract.evaluation_retry_count + 1 - })), - } - } - } - - ContractToolRequest::RequestRework { contract_id: target_contract_id, feedback } => { - // Get the directive contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Verify the target contract is part of this chain - let chain_contract = match repository::get_chain_contract_by_contract_id(pool, target_contract_id).await { - Ok(Some(cc)) => cc, - Ok(None) => return ContractRequestResult { - success: false, - message: format!("Contract {} is not part of a chain", target_contract_id), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - if chain_contract.chain_id != chain_id { - return ContractRequestResult { - success: false, - message: "Contract is not part of this directive's chain".to_string(), - data: None, - }; - } - - // Check retry count - let max_retries = chain_contract.max_evaluation_retries; - if chain_contract.evaluation_retry_count >= max_retries { - return ContractRequestResult { - success: false, - message: format!("Contract has exceeded max retries ({}/{}). Escalate to user.", - chain_contract.evaluation_retry_count, max_retries), - data: Some(json!({ - "retry_count": chain_contract.evaluation_retry_count, - "max_retries": max_retries, - "escalation_required": true - })), - }; - } - - // Mark contract for rework and increment retry count - if let Err(e) = sqlx::query( - r#" - UPDATE chain_contracts - SET evaluation_status = 'rework', - rework_feedback = $2, - evaluation_retry_count = evaluation_retry_count + 1 - WHERE id = $1; - UPDATE contracts SET status = 'active' WHERE id = (SELECT contract_id FROM chain_contracts WHERE id = $1); - "# - ) - .bind(chain_contract.id) - .bind(&feedback) - .execute(pool) - .await { - return ContractRequestResult { - success: false, - message: format!("Failed to request rework: {}", e), - data: None, - }; - } + // Chain directive tools - TEMPORARILY DISABLED + // These tools will be reimplemented using the new directive system. + // See the orchestration module for the new implementation. + ContractToolRequest::CreateChainFromDirective { .. } | + ContractToolRequest::AddChainContract { .. } | + ContractToolRequest::SetChainDependencies { .. } | + ContractToolRequest::ModifyChainContract { .. } | + ContractToolRequest::RemoveChainContract { .. } | + ContractToolRequest::PreviewChainDag | + ContractToolRequest::ValidateChainDirective | + ContractToolRequest::FinalizeChainDirective { .. } | + ContractToolRequest::GetChainStatus | + ContractToolRequest::GetUncoveredRequirements | + ContractToolRequest::EvaluateContractCompletion { .. } | + ContractToolRequest::RequestRework { .. } => { ContractRequestResult { - success: true, - message: format!("Rework requested for contract. Retry {}/{}", - chain_contract.evaluation_retry_count + 1, max_retries), - data: Some(json!({ - "contract_id": target_contract_id, - "retry_count": chain_contract.evaluation_retry_count + 1, - "max_retries": max_retries, - "feedback": feedback - })), + success: false, + message: "Chain directive tools are temporarily disabled. The directive system is being reimplemented.".to_string(), + data: None, } } } diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index 2b2fc26..8a6ce0f 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -575,78 +575,90 @@ pub async fn update_contract( }), ).await; - // If contract is part of a chain, check evaluation requirements - if let Some(chain_id) = contract.chain_id { - let pool_clone = pool.clone(); - let owner_id = auth.owner_id; - let contract_id = contract.id; - tokio::spawn(async move { - // Check if chain has evaluation enabled - let chain = match repository::get_chain_for_owner(&pool_clone, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - tracing::warn!(chain_id = %chain_id, "Chain not found for progression"); - return; - } - Err(e) => { - tracing::error!(chain_id = %chain_id, error = %e, "Failed to get chain"); - return; - } - }; - - // If evaluation is enabled, mark contract for evaluation - if chain.evaluation_enabled { - // Mark the chain_contract as pending evaluation - if let Ok(Some(chain_contract)) = repository::get_chain_contract_by_contract_id(&pool_clone, contract_id).await { - if let Err(e) = repository::update_chain_contract_evaluation_status( - &pool_clone, - chain_contract.id, - "pending_evaluation", - None, - None, - ).await { - tracing::error!( - chain_id = %chain_id, - contract_id = %contract_id, - error = %e, - "Failed to mark contract for evaluation" - ); - } else { - tracing::info!( - chain_id = %chain_id, - contract_id = %contract_id, - "Contract marked for evaluation - waiting for directive contract to evaluate" + // If contract is part of a directive chain step, update the step status + // and emit an event for the directive engine to process + let pool_for_step = pool.clone(); + let contract_id_for_step = contract.id; + tokio::spawn(async move { + // Look up the step by contract_id + match repository::get_step_by_contract_id(&pool_for_step, contract_id_for_step).await { + Ok(Some(step)) => { + // Get the chain to find the directive_id + let directive_id = match repository::get_directive_chain(&pool_for_step, step.chain_id).await { + Ok(Some(chain)) => chain.directive_id, + Ok(None) => { + tracing::warn!( + chain_id = %step.chain_id, + "Chain not found for step" ); + return; } - } - // Don't progress chain - directive contract will evaluate and progress - return; - } - - // If evaluation is disabled, progress chain directly - match repository::progress_chain(&pool_clone, chain_id, owner_id).await { - Ok(result) => { - if !result.contracts_created.is_empty() { - tracing::info!( - chain_id = %chain_id, - contracts_created = ?result.contracts_created, - "Chain progressed - created new contracts" + Err(e) => { + tracing::warn!( + chain_id = %step.chain_id, + error = %e, + "Failed to get chain for step" ); + return; } - if result.chain_completed { - tracing::info!(chain_id = %chain_id, "Chain completed"); - } - } - Err(e) => { - tracing::error!( - chain_id = %chain_id, + }; + + // Update step status to 'evaluating' + if let Err(e) = repository::update_step_status(&pool_for_step, step.id, "evaluating").await { + tracing::warn!( + step_id = %step.id, + contract_id = %contract_id_for_step, error = %e, - "Failed to progress chain after contract completion" + "Failed to update step status to evaluating" ); + } else { + tracing::info!( + step_id = %step.id, + contract_id = %contract_id_for_step, + chain_id = %step.chain_id, + directive_id = %directive_id, + "Contract completed - step transitioned to evaluating" + ); + + // Emit directive event for contract completion + if let Err(e) = repository::emit_directive_event( + &pool_for_step, + directive_id, + Some(step.chain_id), + Some(step.id), + "contract_completed", + "info", + Some(serde_json::json!({ + "contract_id": contract_id_for_step, + "step_id": step.id, + "step_name": step.name + })), + "system", + None, + ).await { + tracing::warn!( + step_id = %step.id, + error = %e, + "Failed to emit contract_completed directive event" + ); + } } } - }); - } + Ok(None) => { + tracing::debug!( + contract_id = %contract_id_for_step, + "Contract not linked to any directive chain step" + ); + } + Err(e) => { + tracing::warn!( + contract_id = %contract_id_for_step, + error = %e, + "Failed to look up step for completed contract" + ); + } + } + }); } // Get summary with counts diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs new file mode 100644 index 0000000..6f6c3f1 --- /dev/null +++ b/makima/src/server/handlers/directives.rs @@ -0,0 +1,1488 @@ +//! API handlers for directives. +//! +//! Provides REST endpoints for managing directives, chains, steps, +//! evaluations, events, verifiers, and approvals. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{ + sse::{Event, Sse}, + IntoResponse, + }, + Json, +}; +use futures::stream; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use std::time::Duration; +use uuid::Uuid; + +use crate::db::models::{ + AddStepRequest, CreateDirectiveRequest, CreateVerifierRequest, UpdateDirectiveRequest, + UpdateStepRequest, UpdateVerifierRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// Query parameters for listing directives +#[derive(Debug, Deserialize)] +pub struct ListDirectivesQuery { + pub status: Option<String>, +} + +/// Query parameters for listing events +#[derive(Debug, Deserialize)] +pub struct ListEventsQuery { + pub limit: Option<i64>, +} + +/// Query parameters for listing evaluations +#[derive(Debug, Deserialize)] +pub struct ListEvaluationsQuery { + pub limit: Option<i64>, + #[serde(rename = "stepId")] + pub step_id: Option<Uuid>, +} + +/// Response for directive creation +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveResponse { + pub id: Uuid, + pub title: String, + pub status: String, +} + +/// Response for approval actions +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalActionRequest { + pub response: Option<String>, +} + +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// Create a new directive +/// POST /api/v1/directives +pub async fn create_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateDirectiveRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::create_directive_for_owner(pool, auth.owner_id, req).await { + Ok(directive) => Json(CreateDirectiveResponse { + id: directive.id, + title: directive.title, + status: directive.status, + }) + .into_response(), + Err(e) => { + tracing::error!("Failed to create directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// List directives for the authenticated owner +/// GET /api/v1/directives +pub async fn list_directives( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Query(params): Query<ListDirectivesQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::list_directives_for_owner(pool, auth.owner_id, params.status.as_deref()).await + { + Ok(directives) => Json(directives).into_response(), + Err(e) => { + tracing::error!("Failed to list directives: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a directive with progress details +/// GET /api/v1/directives/:id +pub async fn get_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_directive_with_progress(pool, id, auth.owner_id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive +/// PUT /api/v1/directives/:id +pub async fn update_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateDirectiveRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::update_directive_for_owner(pool, id, auth.owner_id, req).await { + Ok(directive) => Json(directive).into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!( + "Version conflict: expected {}, got {}", + expected, actual + ), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Archive a directive +/// DELETE /api/v1/directives/:id +pub async fn archive_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::archive_directive_for_owner(pool, id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to archive directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Directive Lifecycle +// ============================================================================= + +/// Start a directive (generate chain and begin execution) +/// POST /api/v1/directives/:id/start +pub async fn start_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Start directive via orchestration engine + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.start_directive(id).await { + Ok(()) => { + // Return the updated directive with progress + match repository::get_directive_with_progress(pool, id, auth.owner_id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to start directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("START_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pause a directive +/// POST /api/v1/directives/:id/pause +pub async fn pause_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.pause_directive(id).await { + Ok(()) => match repository::get_directive(pool, id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + }, + Err(e) => { + tracing::error!("Failed to pause directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("PAUSE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Resume a paused directive +/// POST /api/v1/directives/:id/resume +pub async fn resume_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.resume_directive(id).await { + Ok(()) => match repository::get_directive_with_progress(pool, id, auth.owner_id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + }, + Err(e) => { + tracing::error!("Failed to resume directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("RESUME_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Stop a directive (cannot be resumed) +/// POST /api/v1/directives/:id/stop +pub async fn stop_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.stop_directive(id).await { + Ok(()) => match repository::get_directive(pool, id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + }, + Err(e) => { + tracing::error!("Failed to stop directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("STOP_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Chain Management +// ============================================================================= + +/// Get current chain for a directive +/// GET /api/v1/directives/:id/chain +pub async fn get_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::get_current_chain(pool, id).await { + Ok(Some(chain)) => { + match repository::list_chain_steps(pool, chain.id).await { + Ok(steps) => Json(serde_json::json!({ + "chain": chain, + "steps": steps, + })) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "No active chain")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get chain graph for DAG visualization +/// GET /api/v1/directives/:id/chain/graph +pub async fn get_chain_graph( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Get current chain + let chain = match repository::get_current_chain(pool, id).await { + Ok(Some(chain)) => chain, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "No active chain")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + match repository::get_chain_graph(pool, chain.id).await { + Ok(graph) => Json(graph).into_response(), + Err(e) => { + tracing::error!("Failed to get chain graph: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Regenerate chain (force replan) +/// POST /api/v1/directives/:id/chain/replan +pub async fn replan_chain( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.regenerate_chain(id, "Manual replan requested").await { + Ok(new_chain_id) => Json(serde_json::json!({ + "chainId": new_chain_id, + "message": "Chain regenerated successfully", + })) + .into_response(), + Err(e) => { + tracing::error!("Failed to replan chain: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("REPLAN_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Step Management +// ============================================================================= + +/// Add a step to the current chain +/// POST /api/v1/directives/:id/chain/steps +pub async fn add_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<AddStepRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Get current chain + let chain = match repository::get_current_chain(pool, id).await { + Ok(Some(chain)) => chain, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "No active chain")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + match repository::create_chain_step(pool, chain.id, req).await { + Ok(step) => (StatusCode::CREATED, Json(step)).into_response(), + Err(e) => { + tracing::error!("Failed to add step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get step details +/// GET /api/v1/directives/:id/chain/steps/:step_id +pub async fn get_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::get_chain_step(pool, step_id).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a step +/// PUT /api/v1/directives/:id/chain/steps/:step_id +pub async fn update_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateStepRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::update_chain_step(pool, step_id, req).await { + Ok(step) => Json(step).into_response(), + Err(e) => { + tracing::error!("Failed to update step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a step +/// DELETE /api/v1/directives/:id/chain/steps/:step_id +pub async fn delete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::delete_chain_step(pool, step_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Skip a step +/// POST /api/v1/directives/:id/chain/steps/:step_id/skip +pub async fn skip_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::update_step_status(pool, step_id, "skipped").await { + Ok(step) => Json(step).into_response(), + Err(e) => { + tracing::error!("Failed to skip step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Evaluations +// ============================================================================= + +/// List evaluations for a directive +/// GET /api/v1/directives/:id/evaluations +pub async fn list_evaluations( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Query(params): Query<ListEvaluationsQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let result = if let Some(step_id) = params.step_id { + repository::list_step_evaluations(pool, step_id).await + } else { + repository::list_directive_evaluations(pool, id, params.limit).await + }; + + match result { + Ok(evaluations) => Json(evaluations).into_response(), + Err(e) => { + tracing::error!("Failed to list evaluations: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Events +// ============================================================================= + +/// List events for a directive +/// GET /api/v1/directives/:id/events +pub async fn list_events( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Query(params): Query<ListEventsQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::list_directive_events(pool, id, params.limit).await { + Ok(events) => Json(events).into_response(), + Err(e) => { + tracing::error!("Failed to list events: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// SSE stream of events for a directive +/// GET /api/v1/directives/:id/events/stream +pub async fn stream_events( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Create SSE stream that polls for new events + let pool_clone = pool.clone(); + let stream = stream::unfold( + (pool_clone, id, None::<chrono::DateTime<chrono::Utc>>), + move |(pool, directive_id, last_seen)| async move { + // Wait a bit before next poll + tokio::time::sleep(Duration::from_secs(1)).await; + + // Get recent events + let events = repository::list_directive_events(&pool, directive_id, Some(10)) + .await + .unwrap_or_default(); + + // Filter to only new events + let new_events: Vec<_> = events + .into_iter() + .filter(|e| last_seen.map(|ls| e.created_at > ls).unwrap_or(true)) + .collect(); + + let new_last_seen = new_events.first().map(|e| e.created_at).or(last_seen); + + // Convert to SSE events + let sse_events: Vec<Result<Event, Infallible>> = new_events + .into_iter() + .map(|e| { + Ok(Event::default() + .event("directive_event") + .data(serde_json::to_string(&e).unwrap_or_default())) + }) + .collect(); + + Some((stream::iter(sse_events), (pool, directive_id, new_last_seen))) + }, + ); + + use futures::StreamExt; + Sse::new(stream.flatten()) + .keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keepalive"), + ) + .into_response() +} + +// ============================================================================= +// Verifiers +// ============================================================================= + +/// List verifiers for a directive +/// GET /api/v1/directives/:id/verifiers +pub async fn list_verifiers( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::list_directive_verifiers(pool, id).await { + Ok(verifiers) => Json(verifiers).into_response(), + Err(e) => { + tracing::error!("Failed to list verifiers: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Add a verifier to a directive +/// POST /api/v1/directives/:id/verifiers +pub async fn add_verifier( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateVerifierRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::create_directive_verifier( + pool, + id, + &req.name, + &req.verifier_type, + req.command.as_deref(), + req.working_directory.as_deref(), + false, // auto_detect + vec![], // detect_files + req.weight.unwrap_or(1.0), + req.required.unwrap_or(false), + ) + .await + { + Ok(verifier) => (StatusCode::CREATED, Json(verifier)).into_response(), + Err(e) => { + tracing::error!("Failed to add verifier: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a verifier +/// PUT /api/v1/directives/:id/verifiers/:verifier_id +pub async fn update_verifier( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, verifier_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateVerifierRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::update_directive_verifier( + pool, + verifier_id, + req.enabled, + req.command.as_deref(), + req.weight, + req.required, + ) + .await + { + Ok(verifier) => Json(verifier).into_response(), + Err(e) => { + tracing::error!("Failed to update verifier: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Approvals +// ============================================================================= + +/// List pending approvals for a directive +/// GET /api/v1/directives/:id/approvals +pub async fn list_approvals( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::list_pending_approvals(pool, id).await { + Ok(approvals) => Json(approvals).into_response(), + Err(e) => { + tracing::error!("Failed to list approvals: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Approve a pending approval request +/// POST /api/v1/directives/:id/approvals/:approval_id/approve +pub async fn approve_request( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, approval_id)): Path<(Uuid, Uuid)>, + Json(req): Json<ApprovalActionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine + .on_approval_resolved(approval_id, true, auth.owner_id) + .await + { + Ok(()) => { + match repository::resolve_approval( + pool, + approval_id, + "approved", + req.response.as_deref(), + auth.owner_id, + ) + .await + { + Ok(approval) => Json(approval).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to process approval: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("APPROVAL_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Deny a pending approval request +/// POST /api/v1/directives/:id/approvals/:approval_id/deny +pub async fn deny_request( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, approval_id)): Path<(Uuid, Uuid)>, + Json(req): Json<ApprovalActionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine + .on_approval_resolved(approval_id, false, auth.owner_id) + .await + { + Ok(()) => { + match repository::resolve_approval( + pool, + approval_id, + "denied", + req.response.as_deref(), + auth.owner_id, + ) + .await + { + Ok(approval) => Json(approval).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to process denial: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("DENIAL_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 5e172bc..d3fabf7 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,7 +1,8 @@ //! HTTP and WebSocket request handlers. pub mod api_keys; -pub mod chains; +// pub mod chains; // Removed - replaced by directives +pub mod directives; pub mod chat; pub mod contract_chat; pub mod contract_daemon; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index e5b55e7..927e9a5 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -214,51 +214,55 @@ pub fn make_router(state: SharedState) -> Router { ) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) - // Chain endpoints (multi-contract orchestration) + // Directive endpoints (replacement for chains) .route( - "/chains", - get(chains::list_chains).post(chains::create_chain), + "/directives", + get(directives::list_directives).post(directives::create_directive), ) - .route("/chains/init", post(chains::init_chain)) .route( - "/chains/{id}", - get(chains::get_chain) - .put(chains::update_chain) - .delete(chains::delete_chain), + "/directives/{id}", + get(directives::get_directive) + .put(directives::update_directive) + .delete(directives::archive_directive), ) - .route("/chains/{id}/contracts", get(chains::get_chain_contracts)) - .route("/chains/{id}/graph", get(chains::get_chain_graph)) - .route("/chains/{id}/events", get(chains::get_chain_events)) - .route("/chains/{id}/editor", get(chains::get_chain_editor)) - // Chain contract definitions + .route("/directives/{id}/start", post(directives::start_directive)) + .route("/directives/{id}/pause", post(directives::pause_directive)) + .route("/directives/{id}/resume", post(directives::resume_directive)) + .route("/directives/{id}/stop", post(directives::stop_directive)) + // Directive chain management + .route("/directives/{id}/chain", get(directives::get_chain)) + .route("/directives/{id}/chain/graph", get(directives::get_chain_graph)) + .route("/directives/{id}/chain/replan", post(directives::replan_chain)) + // Directive step management .route( - "/chains/{id}/definitions", - get(chains::list_chain_definitions).post(chains::create_chain_definition), + "/directives/{id}/chain/steps", + post(directives::add_step), ) .route( - "/chains/{chain_id}/definitions/{definition_id}", - put(chains::update_chain_definition).delete(chains::delete_chain_definition), + "/directives/{id}/chain/steps/{step_id}", + get(directives::get_step) + .put(directives::update_step) + .delete(directives::delete_step), ) + .route("/directives/{id}/chain/steps/{step_id}/skip", post(directives::skip_step)) + // Directive evaluations + .route("/directives/{id}/evaluations", get(directives::list_evaluations)) + // Directive events + .route("/directives/{id}/events", get(directives::list_events)) + .route("/directives/{id}/events/stream", get(directives::stream_events)) + // Directive verifiers .route( - "/chains/{id}/definitions/graph", - get(chains::get_chain_definition_graph), + "/directives/{id}/verifiers", + get(directives::list_verifiers).post(directives::add_verifier), ) - // Chain control - .route("/chains/{id}/start", post(chains::start_chain)) - .route("/chains/{id}/stop", post(chains::stop_chain)) - // Chain repositories .route( - "/chains/{id}/repositories", - get(chains::list_chain_repositories).post(chains::add_chain_repository), - ) - .route( - "/chains/{chain_id}/repositories/{repository_id}", - axum::routing::delete(chains::delete_chain_repository), - ) - .route( - "/chains/{chain_id}/repositories/{repository_id}/primary", - put(chains::set_chain_repository_primary), + "/directives/{id}/verifiers/{verifier_id}", + axum::routing::put(directives::update_verifier), ) + // Directive approvals + .route("/directives/{id}/approvals", get(directives::list_approvals)) + .route("/directives/{id}/approvals/{approval_id}/approve", post(directives::approve_request)) + .route("/directives/{id}/approvals/{approval_id}/deny", post(directives::deny_request)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
