diff options
| author | soryu <soryu@soryu.co> | 2026-01-29 02:56:44 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-29 02:56:44 +0000 |
| commit | f19acd400cc5bbe1fe51c004c50ee90d704240d8 (patch) | |
| tree | b7dcfd6926efcafd6eac33e713ebd321ec4284d0 | |
| parent | 7af8561677cfdcfd23d099a25783c7fef51d1ba6 (diff) | |
| download | soryu-f19acd400cc5bbe1fe51c004c50ee90d704240d8.tar.gz soryu-f19acd400cc5bbe1fe51c004c50ee90d704240d8.zip | |
Fix contract type selection
| -rw-r--r-- | makima/frontend/src/components/templates/TemplateEditor.tsx | 31 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 120 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 46 | ||||
| -rw-r--r-- | makima/frontend/src/routes/templates.tsx | 248 | ||||
| -rw-r--r-- | makima/migrations/20260130000000_create_contract_templates.sql | 19 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 213 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 344 | ||||
| -rw-r--r-- | makima/src/llm/phase_guidance.rs | 86 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/templates.rs | 420 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 11 |
12 files changed, 1367 insertions, 173 deletions
diff --git a/makima/frontend/src/components/templates/TemplateEditor.tsx b/makima/frontend/src/components/templates/TemplateEditor.tsx index 03382f3..c8e1f98 100644 --- a/makima/frontend/src/components/templates/TemplateEditor.tsx +++ b/makima/frontend/src/components/templates/TemplateEditor.tsx @@ -5,9 +5,10 @@ interface Props { template: ContractTemplate; onSave: (template: ContractTemplate) => void; onCancel: () => void; + readOnly?: boolean; } -export function TemplateEditor({ template, onSave, onCancel }: Props) { +export function TemplateEditor({ template, onSave, onCancel, readOnly = false }: Props) { const [editedTemplate, setEditedTemplate] = useState<ContractTemplate>({ ...template, phases: template.phases.map((p) => ({ @@ -106,11 +107,16 @@ export function TemplateEditor({ template, onSave, onCancel }: Props) { {/* Header */} <div className="mb-6 pb-4 border-b border-[rgba(117,170,252,0.15)]"> <h2 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff] mb-1"> - Edit Template: {template.name} + {readOnly ? "View" : "Edit"} Template: {template.name} </h2> <p className="text-xs font-mono text-[#75aafc] opacity-70"> {template.description} </p> + {readOnly && ( + <p className="text-xs font-mono text-amber-400 mt-2"> + Built-in templates are read-only + </p> + )} </div> {/* Phases */} @@ -127,10 +133,11 @@ export function TemplateEditor({ template, onSave, onCancel }: Props) { </span> <input type="text" - className="flex-1 px-3 py-1.5 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]" + className="flex-1 px-3 py-1.5 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3] disabled:opacity-60" value={phase.name} onChange={(e) => handlePhaseNameChange(phase.id, e.target.value)} placeholder="Phase name" + disabled={readOnly} /> {!template.isBuiltIn && ( <button @@ -233,15 +240,17 @@ export function TemplateEditor({ template, onSave, onCancel }: Props) { onClick={onCancel} className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" > - Cancel - </button> - <button - type="button" - onClick={() => onSave(editedTemplate)} - className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" - > - Save Changes + {readOnly ? "Close" : "Cancel"} </button> + {!readOnly && ( + <button + type="button" + onClick={() => onSave(editedTemplate)} + className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" + > + Save Changes + </button> + )} </div> </div> ); diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 8838dbd..e8b3d8a 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1645,6 +1645,56 @@ export interface ListContractTypesResponse { contractTypes: ContractTypeTemplate[]; } +/** Phase definition for custom templates */ +export interface PhaseDefinition { + id: string; + name: string; + order: number; +} + +/** Deliverable definition for custom templates */ +export interface DeliverableDefinition { + id: string; + name: string; + priority: "required" | "recommended" | "optional"; +} + +/** Request to create a custom contract type template */ +export interface CreateTemplateRequest { + name: string; + description?: string; + phases: PhaseDefinition[]; + defaultPhase: string; + deliverables?: Record<string, DeliverableDefinition[]>; +} + +/** Request to update a custom contract type template */ +export interface UpdateTemplateRequest { + name?: string; + description?: string; + phases?: PhaseDefinition[]; + defaultPhase?: string; + deliverables?: Record<string, DeliverableDefinition[]>; + version?: number; +} + +/** Custom template record from the API */ +export interface ContractTypeTemplateRecord { + id: string; + name: string; + description: string | null; + phases: PhaseDefinition[]; + defaultPhase: string; + isBuiltin: boolean; + version: number; + createdAt: string; +} + +/** Response for single template operations */ +export interface TemplateResponse { + template: ContractTypeTemplateRecord; +} + /** * List available contract types/templates. * Returns built-in types (simple, specification) and any custom types. @@ -1657,6 +1707,66 @@ export async function listContractTypes(): Promise<ListContractTypesResponse> { return res.json(); } +/** + * Create a new custom contract type template. + */ +export async function createContractTemplate( + req: CreateTemplateRequest +): Promise<TemplateResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(err.message || `Failed to create template: ${res.statusText}`); + } + return res.json(); +} + +/** + * Get a custom contract type template by ID. + */ +export async function getContractTemplate(id: string): Promise<TemplateResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types/${id}`); + if (!res.ok) { + throw new Error(`Failed to get template: ${res.statusText}`); + } + return res.json(); +} + +/** + * Update a custom contract type template. + */ +export async function updateContractTemplate( + id: string, + req: UpdateTemplateRequest +): Promise<TemplateResponse> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(err.message || `Failed to update template: ${res.statusText}`); + } + return res.json(); +} + +/** + * Delete a custom contract type template. + */ +export async function deleteContractTemplate(id: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/contract-types/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to delete template: ${res.statusText}`); + } +} + export interface ContractRepository { id: string; contractId: string; @@ -1741,10 +1851,12 @@ export interface ContractListResponse { export interface CreateContractRequest { name: string; description?: string; - /** Contract type: "simple" (default) or "specification" */ - contractType?: ContractType; - /** Initial phase to start in (defaults based on contract type) */ - initialPhase?: ContractPhase; + /** Contract type: "simple" (default), "specification", "execute", or custom template name */ + contractType?: ContractType | string; + /** UUID of a custom template to use. If provided, takes precedence over contractType. */ + templateId?: string; + /** Initial phase to start in (defaults based on contract type or template) */ + initialPhase?: ContractPhase | string; /** When true, tasks won't auto-push or create PRs - use patch files instead */ localOnly?: boolean; /** When true, spawn a red team task to monitor work output */ diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index bb66215..fc76f60 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -96,49 +96,19 @@ function ContractsPageContent() { const [redTeamEnabled, setRedTeamEnabled] = useState(false); const [redTeamPrompt, setRedTeamPrompt] = useState(""); - // Fetch contract types when modal opens - merges built-in types with user templates + // Fetch contract types when modal opens - API returns both built-in and custom templates useEffect(() => { if (isCreating) { setContractTypesLoading(true); - // Load user templates from localStorage - const loadUserTemplates = (): ContractTypeTemplate[] => { - try { - const saved = localStorage.getItem("makima_contract_templates"); - if (saved) { - const templates = JSON.parse(saved); - // Convert user templates to ContractTypeTemplate format, excluding built-ins - return templates - .filter((t: { isBuiltIn?: boolean }) => !t.isBuiltIn) - .map((t: { id: string; name: string; description: string; phases: { id: string; name: string }[] }) => ({ - id: t.id, - name: t.name, - description: t.description, - phases: t.phases.map((p: { id: string }) => p.id) as ContractPhase[], - phaseNames: Object.fromEntries(t.phases.map((p: { id: string; name: string }) => [p.id, p.name])), - defaultPhase: (t.phases[0]?.id || "execute") as ContractPhase, - isBuiltin: false, - })); - } - } catch { - // Ignore localStorage errors - } - return []; - }; - listContractTypes() .then((res) => { - // Merge built-in types from API with user templates from localStorage - const userTemplates = loadUserTemplates(); - // Filter out any user templates that have the same ID as built-in types - const builtinIds = new Set(res.contractTypes.map(t => t.id)); - const uniqueUserTemplates = userTemplates.filter(t => !builtinIds.has(t.id)); - setContractTypes([...res.contractTypes, ...uniqueUserTemplates]); + setContractTypes(res.contractTypes); setContractTypesLoading(false); }) .catch((err) => { console.error("Failed to fetch contract types:", err); - // Fall back to built-in types + user templates + // Fall back to built-in types const builtinTypes: ContractTypeTemplate[] = [ { id: "simple", @@ -165,10 +135,7 @@ function ContractsPageContent() { isBuiltin: true, }, ]; - const userTemplates = loadUserTemplates(); - const builtinIds = new Set(builtinTypes.map(t => t.id)); - const uniqueUserTemplates = userTemplates.filter(t => !builtinIds.has(t.id)); - setContractTypes([...builtinTypes, ...uniqueUserTemplates]); + setContractTypes(builtinTypes); setContractTypesLoading(false); }); } @@ -261,11 +228,14 @@ function ContractsPageContent() { // Get default phase from contract types or fall back to static function const selectedType = contractTypes.find((t) => t.id === contractType); const defaultPhaseForType = selectedType?.defaultPhase || (contractType === "simple" ? "plan" : "research"); + const isCustomTemplate = selectedType && !selectedType.isBuiltin; const data: CreateContractRequest = { name: newContractName.trim(), description: newContractDescription.trim() || undefined, - contractType: contractType, + // For custom templates, send templateId instead of contractType + contractType: isCustomTemplate ? undefined : contractType, + templateId: isCustomTemplate ? contractType : undefined, initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined, localOnly: localOnly || undefined, redTeamEnabled: redTeamEnabled || undefined, diff --git a/makima/frontend/src/routes/templates.tsx b/makima/frontend/src/routes/templates.tsx index 15bf95c..b2c9974 100644 --- a/makima/frontend/src/routes/templates.tsx +++ b/makima/frontend/src/routes/templates.tsx @@ -1,28 +1,27 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { TemplateEditor } from "../components/templates/TemplateEditor"; import { useAuth } from "../contexts/AuthContext"; import type { ContractTemplate } from "../types/templates"; import { DEFAULT_TEMPLATES } from "../types/templates"; - -const STORAGE_KEY = "makima_contract_templates"; +import { + listContractTypes, + createContractTemplate, + updateContractTemplate, + deleteContractTemplate, + type PhaseDefinition, + type DeliverableDefinition, +} from "../lib/api"; export default function TemplatesPage() { const navigate = useNavigate(); const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const [templates, setTemplates] = useState<ContractTemplate[]>(() => { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return DEFAULT_TEMPLATES; - } - } - return DEFAULT_TEMPLATES; - }); + const [templates, setTemplates] = useState<ContractTemplate[]>(DEFAULT_TEMPLATES); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [saving, setSaving] = useState(false); const [editingTemplate, setEditingTemplate] = useState<ContractTemplate | null>( null @@ -38,63 +37,161 @@ export default function TemplatesPage() { } }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - const saveTemplates = (newTemplates: ContractTemplate[]) => { - setTemplates(newTemplates); - localStorage.setItem(STORAGE_KEY, JSON.stringify(newTemplates)); - }; + // Fetch templates from API + const fetchTemplates = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await listContractTypes(); - const handleSaveTemplate = (updatedTemplate: ContractTemplate) => { - const newTemplates = templates.map((t) => - t.id === updatedTemplate.id ? updatedTemplate : t - ); - saveTemplates(newTemplates); - setEditingTemplate(null); + // Convert API response to ContractTemplate format + const apiTemplates: ContractTemplate[] = response.contractTypes.map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + isBuiltIn: t.isBuiltin, + phases: t.phases.map((phaseId) => ({ + id: phaseId, + name: t.phaseNames?.[phaseId] || phaseId.charAt(0).toUpperCase() + phaseId.slice(1), + deliverables: [], // Deliverables are managed server-side + })), + })); + + // Merge with DEFAULT_TEMPLATES to ensure we have full phase/deliverable info for built-ins + const mergedTemplates = apiTemplates.map((apiTemplate) => { + const defaultTemplate = DEFAULT_TEMPLATES.find((d) => d.id === apiTemplate.id); + if (defaultTemplate && apiTemplate.isBuiltIn) { + return defaultTemplate; // Use the richer default template for built-ins + } + return apiTemplate; + }); + + setTemplates(mergedTemplates); + } catch (err) { + console.error("Failed to fetch templates:", err); + setError(err instanceof Error ? err.message : "Failed to fetch templates"); + // Fall back to default templates + setTemplates(DEFAULT_TEMPLATES); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!authLoading && isAuthenticated) { + fetchTemplates(); + } else if (!authLoading && !isAuthConfigured) { + // No auth configured, just show defaults + setTemplates(DEFAULT_TEMPLATES); + setLoading(false); + } + }, [authLoading, isAuthenticated, isAuthConfigured, fetchTemplates]); + + const handleSaveTemplate = async (updatedTemplate: ContractTemplate) => { + if (updatedTemplate.isBuiltIn) { + // Built-in templates are read-only, just close the editor + setEditingTemplate(null); + return; + } + + try { + setSaving(true); + setError(null); + + // Convert to API format + const phases: PhaseDefinition[] = updatedTemplate.phases.map((p, index) => ({ + id: p.id, + name: p.name, + order: index, + })); + + const deliverables: Record<string, DeliverableDefinition[]> = {}; + for (const phase of updatedTemplate.phases) { + if (phase.deliverables.length > 0) { + deliverables[phase.id] = phase.deliverables.map((d) => ({ + id: d.id, + name: d.name, + priority: "required" as const, + })); + } + } + + await updateContractTemplate(updatedTemplate.id, { + name: updatedTemplate.name, + description: updatedTemplate.description, + phases, + defaultPhase: phases[0]?.id || "execute", + deliverables: Object.keys(deliverables).length > 0 ? deliverables : undefined, + }); + + // Refresh templates from server + await fetchTemplates(); + setEditingTemplate(null); + } catch (err) { + console.error("Failed to update template:", err); + setError(err instanceof Error ? err.message : "Failed to update template"); + } finally { + setSaving(false); + } }; - const handleCreateTemplate = () => { + const handleCreateTemplate = async () => { if (!newTemplateName.trim()) return; - const newTemplate: ContractTemplate = { - id: `custom-${Date.now()}`, - name: newTemplateName.trim(), - description: newTemplateDescription.trim() || "Custom contract template", - isBuiltIn: false, - phases: [ - { - id: `phase-${Date.now()}`, - name: "Execute", - deliverables: [], - }, - ], - }; - - saveTemplates([...templates, newTemplate]); - setNewTemplateName(""); - setNewTemplateDescription(""); - setShowNewTemplateForm(false); + try { + setSaving(true); + setError(null); + + const phases: PhaseDefinition[] = [ + { id: "execute", name: "Execute", order: 0 }, + ]; + + await createContractTemplate({ + name: newTemplateName.trim(), + description: newTemplateDescription.trim() || "Custom contract template", + phases, + defaultPhase: "execute", + }); + + // Refresh templates from server + await fetchTemplates(); + + setNewTemplateName(""); + setNewTemplateDescription(""); + setShowNewTemplateForm(false); + } catch (err) { + console.error("Failed to create template:", err); + setError(err instanceof Error ? err.message : "Failed to create template"); + } finally { + setSaving(false); + } }; - const handleDeleteTemplate = (templateId: string) => { + const handleDeleteTemplate = async (templateId: string) => { const template = templates.find((t) => t.id === templateId); if (template?.isBuiltIn) return; if (window.confirm(`Are you sure you want to delete "${template?.name}"?`)) { - saveTemplates(templates.filter((t) => t.id !== templateId)); + try { + setSaving(true); + setError(null); + await deleteContractTemplate(templateId); + await fetchTemplates(); + } catch (err) { + console.error("Failed to delete template:", err); + setError(err instanceof Error ? err.message : "Failed to delete template"); + } finally { + setSaving(false); + } } }; - const handleResetToDefaults = () => { - if ( - window.confirm( - "Reset all templates to defaults? This will remove any custom templates." - ) - ) { - saveTemplates(DEFAULT_TEMPLATES); - } + const handleRefresh = () => { + fetchTemplates(); }; // Show loading state - if (authLoading) { + if (authLoading || loading) { return ( <div className="relative z-10 min-h-screen flex items-center justify-center bg-[#0a1628]"> <div className="text-[#75aafc] font-mono text-sm animate-pulse"> @@ -114,6 +211,7 @@ export default function TemplatesPage() { template={editingTemplate} onSave={handleSaveTemplate} onCancel={() => setEditingTemplate(null)} + readOnly={editingTemplate.isBuiltIn} /> </main> </div> @@ -124,6 +222,20 @@ export default function TemplatesPage() { <div className="relative z-10 min-h-screen bg-[#0a1628]"> <Masthead /> <main className="max-w-6xl mx-auto px-4 py-6"> + {/* Error display */} + {error && ( + <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> + {error} + <button + type="button" + onClick={() => setError(null)} + className="ml-2 text-red-400/70 hover:text-red-400" + > + Dismiss + </button> + </div> + )} + {/* Header */} <div className="flex justify-between items-start mb-6 pb-4 border-b border-[rgba(117,170,252,0.15)]"> <div> @@ -137,15 +249,17 @@ export default function TemplatesPage() { <div className="flex gap-3"> <button type="button" - onClick={handleResetToDefaults} - className="px-3 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" + onClick={handleRefresh} + disabled={saving} + className="px-3 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50" > - Reset to Defaults + Refresh </button> <button type="button" onClick={() => setShowNewTemplateForm(true)} - className="px-3 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" + disabled={saving} + className="px-3 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors disabled:opacity-50" > + New Template </button> @@ -162,6 +276,7 @@ export default function TemplatesPage() { placeholder="Template name..." value={newTemplateName} onChange={(e) => setNewTemplateName(e.target.value)} + disabled={saving} /> <input type="text" @@ -169,18 +284,21 @@ export default function TemplatesPage() { placeholder="Description (optional)..." value={newTemplateDescription} onChange={(e) => setNewTemplateDescription(e.target.value)} + disabled={saving} /> <button type="button" onClick={handleCreateTemplate} - className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors" + disabled={saving || !newTemplateName.trim()} + className="px-4 py-2 border border-[#3f6fb3] bg-[rgba(117,170,252,0.15)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:bg-[rgba(117,170,252,0.25)] transition-colors disabled:opacity-50" > - Create + {saving ? "Creating..." : "Create"} </button> <button type="button" onClick={() => setShowNewTemplateForm(false)} - className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors" + disabled={saving} + className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50" > Cancel </button> @@ -245,15 +363,17 @@ export default function TemplatesPage() { <button type="button" onClick={() => setEditingTemplate(template)} - className="flex-1 px-3 py-1.5 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors" + disabled={saving} + className="flex-1 px-3 py-1.5 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors disabled:opacity-50" > - Edit + {template.isBuiltIn ? "View" : "Edit"} </button> {!template.isBuiltIn && ( <button type="button" onClick={() => handleDeleteTemplate(template.id)} - className="px-3 py-1.5 border border-[rgba(255,100,100,0.25)] text-[#ff6464] font-mono text-xs uppercase tracking-wide hover:border-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.05)] transition-colors" + disabled={saving} + className="px-3 py-1.5 border border-[rgba(255,100,100,0.25)] text-[#ff6464] font-mono text-xs uppercase tracking-wide hover:border-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.05)] transition-colors disabled:opacity-50" > Delete </button> diff --git a/makima/migrations/20260130000000_create_contract_templates.sql b/makima/migrations/20260130000000_create_contract_templates.sql new file mode 100644 index 0000000..17598e2 --- /dev/null +++ b/makima/migrations/20260130000000_create_contract_templates.sql @@ -0,0 +1,19 @@ +-- Create contract_type_templates table for user-defined contract templates +CREATE TABLE contract_type_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + phases JSONB NOT NULL, -- [{id, name, order}] + default_phase VARCHAR(64) NOT NULL, + deliverables JSONB, -- {phase_id: [{id, name, priority}]} + version INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT unique_template_name_per_owner UNIQUE (owner_id, name) +); + +CREATE INDEX idx_contract_type_templates_owner_id ON contract_type_templates(owner_id); + +-- Add phase_config column to contracts (stores copied template config at creation time) +ALTER TABLE contracts ADD COLUMN IF NOT EXISTS phase_config JSONB; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 9e624c9..2eeba87 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -1114,6 +1114,108 @@ pub struct MergeCompleteCheckResponse { } // ============================================================================= +// Contract Type Templates (User-defined) +// ============================================================================= + +/// A phase definition within a contract template +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseDefinition { + /// Phase identifier (e.g., "research", "plan", "execute") + pub id: String, + /// Display name for the phase + pub name: String, + /// Order in the workflow (0-indexed) + pub order: i32, +} + +/// A deliverable definition within a phase +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeliverableDefinition { + /// Deliverable identifier (e.g., "plan-document", "pull-request") + pub id: String, + /// Display name for the deliverable + pub name: String, + /// Priority: "required", "recommended", or "optional" + #[serde(default = "default_priority")] + pub priority: String, +} + +fn default_priority() -> String { + "required".to_string() +} + +/// Phase configuration stored on a contract (copied from template at creation) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PhaseConfig { + /// Ordered list of phases in the workflow + pub phases: Vec<PhaseDefinition>, + /// Default starting phase + pub default_phase: String, + /// Deliverables per phase: { "phase_id": [deliverables] } + #[serde(default)] + pub deliverables: std::collections::HashMap<String, Vec<DeliverableDefinition>>, +} + +/// Contract type template record from the database +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractTypeTemplateRecord { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option<String>, + #[sqlx(json)] + pub phases: Vec<PhaseDefinition>, + pub default_phase: String, + #[sqlx(json)] + pub deliverables: Option<std::collections::HashMap<String, Vec<DeliverableDefinition>>>, + pub version: i32, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to create a new contract type template +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateTemplateRequest { + pub name: String, + pub description: Option<String>, + pub phases: Vec<PhaseDefinition>, + pub default_phase: String, + pub deliverables: Option<std::collections::HashMap<String, Vec<DeliverableDefinition>>>, +} + +/// Request to update a contract type template +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTemplateRequest { + pub name: Option<String>, + pub description: Option<String>, + pub phases: Option<Vec<PhaseDefinition>>, + pub default_phase: Option<String>, + pub deliverables: Option<std::collections::HashMap<String, Vec<DeliverableDefinition>>>, + /// Version for optimistic locking + pub version: Option<i32>, +} + +/// Summary of a contract type template for list views +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractTypeTemplateSummary { + pub id: Uuid, + pub name: String, + pub description: Option<String>, + pub phases: Vec<PhaseDefinition>, + pub default_phase: String, + pub is_builtin: bool, + pub version: i32, + pub created_at: DateTime<Utc>, +} + +// ============================================================================= // Contract Types // ============================================================================= @@ -1355,6 +1457,11 @@ pub struct Contract { /// when evaluating task outputs. #[serde(skip_serializing_if = "Option::is_none")] pub red_team_prompt: Option<String>, + /// Phase configuration copied from template at contract creation. + /// When present, this overrides the built-in contract type phases. + #[sqlx(json)] + #[serde(skip_serializing_if = "Option::is_none")] + pub phase_config: Option<PhaseConfig>, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -1376,37 +1483,96 @@ impl Contract { self.status.parse() } - /// Get valid phases for this contract type - pub fn valid_phases(&self) -> Vec<ContractPhase> { + /// Get valid phase IDs for this contract (as strings) + pub fn valid_phase_ids(&self) -> Vec<String> { + // Check phase_config first (for custom templates) + if let Some(ref config) = self.phase_config { + let mut phases: Vec<_> = config.phases.iter().collect(); + phases.sort_by_key(|p| p.order); + return phases.iter().map(|p| p.id.clone()).collect(); + } + + // Fall back to built-in contract types match self.contract_type.as_str() { - "simple" => vec![ContractPhase::Plan, ContractPhase::Execute], + "simple" => vec!["plan".to_string(), "execute".to_string()], "specification" => vec![ - ContractPhase::Research, - ContractPhase::Specify, - ContractPhase::Plan, - ContractPhase::Execute, - ContractPhase::Review, + "research".to_string(), + "specify".to_string(), + "plan".to_string(), + "execute".to_string(), + "review".to_string(), ], - "execute" => vec![ContractPhase::Execute], // Execute-only, single phase - _ => vec![ContractPhase::Plan, ContractPhase::Execute], // Default to simple + "execute" => vec!["execute".to_string()], + _ => vec!["plan".to_string(), "execute".to_string()], + } + } + + /// Get valid phases for this contract type (as ContractPhase enums) + /// Note: For custom templates with non-standard phases, this only returns + /// phases that map to the ContractPhase enum. + pub fn valid_phases(&self) -> Vec<ContractPhase> { + self.valid_phase_ids() + .iter() + .filter_map(|id| id.parse::<ContractPhase>().ok()) + .collect() + } + + /// Get the initial phase ID for this contract type (as string) + pub fn initial_phase_id(&self) -> String { + // Check phase_config first (for custom templates) + if let Some(ref config) = self.phase_config { + return config.default_phase.clone(); + } + + // Fall back to built-in contract types + match self.contract_type.as_str() { + "specification" => "research".to_string(), + "execute" => "execute".to_string(), + _ => "plan".to_string(), } } - /// Get the initial phase for this contract type + /// Get the initial phase for this contract type (as ContractPhase enum) pub fn initial_phase(&self) -> ContractPhase { + self.initial_phase_id() + .parse() + .unwrap_or(ContractPhase::Plan) + } + + /// Get the terminal phase ID for this contract type (as string) + pub fn terminal_phase_id(&self) -> String { + // Check phase_config first (for custom templates) + if let Some(ref config) = self.phase_config { + // Last phase in sorted order is the terminal phase + let mut phases: Vec<_> = config.phases.iter().collect(); + phases.sort_by_key(|p| p.order); + if let Some(last) = phases.last() { + return last.id.clone(); + } + } + + // Fall back to built-in contract types match self.contract_type.as_str() { - "specification" => ContractPhase::Research, - "execute" => ContractPhase::Execute, - _ => ContractPhase::Plan, // simple and default + "specification" => "review".to_string(), + _ => "execute".to_string(), } } /// Get the terminal phase for this contract type (phase where contract can be completed) pub fn terminal_phase(&self) -> ContractPhase { - match self.contract_type.as_str() { - "specification" => ContractPhase::Review, - _ => ContractPhase::Execute, // simple and execute both end at execute - } + self.terminal_phase_id() + .parse() + .unwrap_or(ContractPhase::Execute) + } + + /// Check if a phase ID is valid for this contract + pub fn is_valid_phase(&self, phase_id: &str) -> bool { + self.valid_phase_ids().contains(&phase_id.to_string()) + } + + /// Get the phase configuration for custom templates + pub fn get_phase_config(&self) -> Option<&PhaseConfig> { + self.phase_config.as_ref() } /// Get completed deliverable IDs for a specific phase @@ -1507,12 +1673,19 @@ pub struct CreateContractRequest { pub name: String, /// Optional description pub description: Option<String>, - /// Contract type: "simple" (default) or "specification" + /// Contract type: "simple" (default), "specification", "execute", or a custom template name. + /// For built-in types: /// - simple: Plan -> Execute workflow /// - specification: Research -> Specify -> Plan -> Execute -> Review + /// - execute: Execute only + /// For custom templates, use the template name or provide template_id. #[serde(default)] pub contract_type: Option<String>, - /// Initial phase to start in (defaults based on contract_type) + /// UUID of a custom template to use. If provided, this takes precedence over contract_type. + /// The template's phase configuration will be copied to the contract. + #[serde(default)] + pub template_id: Option<Uuid>, + /// Initial phase to start in (defaults based on contract_type or template) /// - simple: defaults to "plan" /// - specification: defaults to "research" #[serde(default)] diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index b947cdd..1ab4165 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -8,11 +8,12 @@ use uuid::Uuid; use super::models::{ CheckpointPatch, CheckpointPatchInfo, Contract, ContractChatConversation, ContractChatMessageRecord, ContractEvent, ContractRepository, ContractSummary, - ConversationMessage, ConversationSnapshot, CreateContractRequest, CreateFileRequest, - CreateTaskRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, File, FileSummary, - FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, + ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, + CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, + DaemonWithCapacity, DeliverableDefinition, File, FileSummary, FileVersion, HistoryEvent, + HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseConfig, PhaseDefinition, RedTeamNotification, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, - UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, + UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, }; /// Repository error types. @@ -2141,68 +2142,349 @@ pub async fn clear_contract_conversation( } // ============================================================================= +// Contract Type Template Functions (Owner-Scoped) +// ============================================================================= + +/// Create a new contract type template for a specific owner. +pub async fn create_template_for_owner( + pool: &PgPool, + owner_id: Uuid, + req: CreateTemplateRequest, +) -> Result<ContractTypeTemplateRecord, sqlx::Error> { + sqlx::query_as::<_, ContractTypeTemplateRecord>( + r#" + INSERT INTO contract_type_templates (owner_id, name, description, phases, default_phase, deliverables) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + "#, + ) + .bind(owner_id) + .bind(&req.name) + .bind(&req.description) + .bind(serde_json::to_value(&req.phases).unwrap_or_default()) + .bind(&req.default_phase) + .bind(req.deliverables.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default())) + .fetch_one(pool) + .await +} + +/// Get a contract type template by ID, scoped to owner. +pub async fn get_template_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<Option<ContractTypeTemplateRecord>, sqlx::Error> { + sqlx::query_as::<_, ContractTypeTemplateRecord>( + r#" + SELECT * + FROM contract_type_templates + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .fetch_optional(pool) + .await +} + +/// Get a contract type template by ID (internal use, no owner scoping). +pub async fn get_template_by_id( + pool: &PgPool, + id: Uuid, +) -> Result<Option<ContractTypeTemplateRecord>, sqlx::Error> { + sqlx::query_as::<_, ContractTypeTemplateRecord>( + r#" + SELECT * + FROM contract_type_templates + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await +} + +/// List all contract type templates for an owner, ordered by name. +pub async fn list_templates_for_owner( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<ContractTypeTemplateRecord>, sqlx::Error> { + sqlx::query_as::<_, ContractTypeTemplateRecord>( + r#" + SELECT * + FROM contract_type_templates + WHERE owner_id = $1 + ORDER BY name ASC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Update a contract type template for an owner. +pub async fn update_template_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, + req: UpdateTemplateRequest, +) -> Result<Option<ContractTypeTemplateRecord>, RepositoryError> { + // Build dynamic update query + let mut query = String::from("UPDATE contract_type_templates SET updated_at = NOW()"); + let mut param_idx = 3; // $1 = id, $2 = owner_id + + if req.name.is_some() { + query.push_str(&format!(", name = ${}", param_idx)); + param_idx += 1; + } + if req.description.is_some() { + query.push_str(&format!(", description = ${}", param_idx)); + param_idx += 1; + } + if req.phases.is_some() { + query.push_str(&format!(", phases = ${}", param_idx)); + param_idx += 1; + } + if req.default_phase.is_some() { + query.push_str(&format!(", default_phase = ${}", param_idx)); + param_idx += 1; + } + if req.deliverables.is_some() { + query.push_str(&format!(", deliverables = ${}", param_idx)); + param_idx += 1; + } + + // Optimistic locking + if req.version.is_some() { + query.push_str(&format!(", version = version + 1 WHERE id = $1 AND owner_id = $2 AND version = ${}", param_idx)); + } else { + query.push_str(", version = version + 1 WHERE id = $1 AND owner_id = $2"); + } + query.push_str(" RETURNING *"); + + let mut sql_query = sqlx::query_as::<_, ContractTypeTemplateRecord>(&query); + sql_query = sql_query.bind(id).bind(owner_id); + + if let Some(ref name) = req.name { + sql_query = sql_query.bind(name); + } + if let Some(ref description) = req.description { + sql_query = sql_query.bind(description); + } + if let Some(ref phases) = req.phases { + sql_query = sql_query.bind(serde_json::to_value(phases).unwrap_or_default()); + } + if let Some(ref default_phase) = req.default_phase { + sql_query = sql_query.bind(default_phase); + } + if let Some(ref deliverables) = req.deliverables { + sql_query = sql_query.bind(serde_json::to_value(deliverables).unwrap_or_default()); + } + if let Some(version) = req.version { + sql_query = sql_query.bind(version); + } + + match sql_query.fetch_optional(pool).await { + Ok(result) => { + if result.is_none() && req.version.is_some() { + // Check if it's a version conflict + if let Some(current) = get_template_for_owner(pool, id, owner_id).await? { + return Err(RepositoryError::VersionConflict { + expected: req.version.unwrap(), + actual: current.version, + }); + } + } + Ok(result) + } + Err(e) => Err(RepositoryError::Database(e)), + } +} + +/// Delete a contract type template for an owner. +pub async fn delete_template_for_owner( + pool: &PgPool, + id: Uuid, + owner_id: Uuid, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM contract_type_templates + WHERE id = $1 AND owner_id = $2 + "#, + ) + .bind(id) + .bind(owner_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Helper function to build PhaseConfig from a template. +pub fn build_phase_config_from_template(template: &ContractTypeTemplateRecord) -> PhaseConfig { + PhaseConfig { + phases: template.phases.clone(), + default_phase: template.default_phase.clone(), + deliverables: template.deliverables.clone().unwrap_or_default(), + } +} + +/// Helper function to build PhaseConfig for built-in contract types. +pub fn build_phase_config_for_builtin(contract_type: &str) -> PhaseConfig { + match contract_type { + "simple" => PhaseConfig { + phases: vec![ + PhaseDefinition { id: "plan".to_string(), name: "Plan".to_string(), order: 0 }, + PhaseDefinition { id: "execute".to_string(), name: "Execute".to_string(), order: 1 }, + ], + default_phase: "plan".to_string(), + deliverables: [ + ("plan".to_string(), vec![DeliverableDefinition { + id: "plan-document".to_string(), + name: "Plan".to_string(), + priority: "required".to_string(), + }]), + ("execute".to_string(), vec![DeliverableDefinition { + id: "pull-request".to_string(), + name: "Pull Request".to_string(), + priority: "required".to_string(), + }]), + ].into_iter().collect(), + }, + "specification" => PhaseConfig { + phases: vec![ + PhaseDefinition { id: "research".to_string(), name: "Research".to_string(), order: 0 }, + PhaseDefinition { id: "specify".to_string(), name: "Specify".to_string(), order: 1 }, + PhaseDefinition { id: "plan".to_string(), name: "Plan".to_string(), order: 2 }, + PhaseDefinition { id: "execute".to_string(), name: "Execute".to_string(), order: 3 }, + PhaseDefinition { id: "review".to_string(), name: "Review".to_string(), order: 4 }, + ], + default_phase: "research".to_string(), + deliverables: [ + ("research".to_string(), vec![DeliverableDefinition { + id: "research-notes".to_string(), + name: "Research Notes".to_string(), + priority: "required".to_string(), + }]), + ("specify".to_string(), vec![DeliverableDefinition { + id: "requirements-document".to_string(), + name: "Requirements Document".to_string(), + priority: "required".to_string(), + }]), + ("plan".to_string(), vec![DeliverableDefinition { + id: "plan-document".to_string(), + name: "Plan".to_string(), + priority: "required".to_string(), + }]), + ("execute".to_string(), vec![DeliverableDefinition { + id: "pull-request".to_string(), + name: "Pull Request".to_string(), + priority: "required".to_string(), + }]), + ("review".to_string(), vec![DeliverableDefinition { + id: "release-notes".to_string(), + name: "Release Notes".to_string(), + priority: "required".to_string(), + }]), + ].into_iter().collect(), + }, + "execute" | _ => PhaseConfig { + phases: vec![ + PhaseDefinition { id: "execute".to_string(), name: "Execute".to_string(), order: 0 }, + ], + default_phase: "execute".to_string(), + deliverables: std::collections::HashMap::new(), + }, + } +} + +// ============================================================================= // Contract Functions (Owner-Scoped) // ============================================================================= /// Create a new contract for a specific owner. +/// Supports both built-in contract types (simple, specification, execute) and custom templates. pub async fn create_contract_for_owner( pool: &PgPool, owner_id: Uuid, req: CreateContractRequest, ) -> Result<Contract, sqlx::Error> { - // Default contract type is "simple" - let contract_type = req.contract_type.as_deref().unwrap_or("simple"); + // Determine phase configuration based on template_id or contract_type + let (phase_config, contract_type_str, default_phase): (PhaseConfig, String, String) = + if let Some(template_id) = req.template_id { + // Look up the custom template + let template = get_template_by_id(pool, template_id) + .await? + .ok_or_else(|| { + sqlx::Error::Protocol(format!("Template not found: {}", template_id)) + })?; + + let config = build_phase_config_from_template(&template); + let default = config.default_phase.clone(); + // For custom templates, store the template name as the contract_type + (config, template.name.clone(), default) + } else { + // Use built-in contract type + let contract_type = req.contract_type.as_deref().unwrap_or("simple"); - // Validate contract type - let valid_types = ["simple", "specification", "execute"]; - if !valid_types.contains(&contract_type) { - return Err(sqlx::Error::Protocol(format!( - "Invalid contract_type '{}'. Must be one of: {}", - contract_type, - valid_types.join(", ") - ))); - } + // Validate contract type + let valid_types = ["simple", "specification", "execute"]; + if !valid_types.contains(&contract_type) { + return Err(sqlx::Error::Protocol(format!( + "Invalid contract_type '{}'. Must be one of: {} or provide a template_id", + contract_type, + valid_types.join(", ") + ))); + } - // Determine valid phases based on contract type - let (valid_phases, default_phase): (&[&str], &str) = match contract_type { - "simple" => (&["plan", "execute"], "plan"), - "specification" => (&["research", "specify", "plan", "execute", "review"], "research"), - "execute" => (&["execute"], "execute"), - _ => (&["plan", "execute"], "plan"), - }; + let config = build_phase_config_for_builtin(contract_type); + let default = config.default_phase.clone(); + (config, contract_type.to_string(), default) + }; - // Use provided initial_phase or default based on contract type - let phase = req.initial_phase.as_deref().unwrap_or(default_phase); + // Get valid phase IDs from the configuration + let valid_phase_ids: Vec<String> = phase_config.phases.iter().map(|p| p.id.clone()).collect(); - // Validate the phase is valid for this contract type - if !valid_phases.contains(&phase) { + // Use provided initial_phase or default based on contract type/template + let phase = req.initial_phase.as_deref().unwrap_or(&default_phase); + + // Validate the phase is valid for this contract type/template + if !valid_phase_ids.contains(&phase.to_string()) { return Err(sqlx::Error::Protocol(format!( "Invalid initial_phase '{}' for contract type '{}'. Must be one of: {}", phase, - contract_type, - valid_phases.join(", ") + contract_type_str, + valid_phase_ids.join(", ") ))); } let autonomous_loop = req.autonomous_loop.unwrap_or(false); let phase_guard = req.phase_guard.unwrap_or(false); let local_only = req.local_only.unwrap_or(false); + let red_team_enabled = req.red_team_enabled.unwrap_or(false); + + // Serialize phase_config to JSON + let phase_config_json = serde_json::to_value(&phase_config).ok(); sqlx::query_as::<_, Contract>( r#" - INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard, local_only) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO contracts (owner_id, name, description, contract_type, phase, autonomous_loop, phase_guard, local_only, red_team_enabled, red_team_prompt, phase_config) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * "#, ) .bind(owner_id) .bind(&req.name) .bind(&req.description) - .bind(contract_type) + .bind(&contract_type_str) .bind(phase) .bind(autonomous_loop) .bind(phase_guard) .bind(local_only) + .bind(red_team_enabled) + .bind(&req.red_team_prompt) + .bind(phase_config_json) .fetch_one(pool) .await } diff --git a/makima/src/llm/phase_guidance.rs b/makima/src/llm/phase_guidance.rs index 379bdca..712e8bb 100644 --- a/makima/src/llm/phase_guidance.rs +++ b/makima/src/llm/phase_guidance.rs @@ -112,6 +112,8 @@ pub struct TaskInfo { pub status: String, } +use crate::db::models::PhaseConfig; + /// Get phase deliverables configuration (legacy, defaults to "simple" contract type) pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables { get_phase_deliverables_for_type(phase, "simple") @@ -126,6 +128,90 @@ pub fn get_phase_deliverables_for_type(phase: &str, contract_type: &str) -> Phas } } +/// Get phase deliverables from a custom PhaseConfig +/// This is used for contracts with custom templates +pub fn get_phase_deliverables_from_config(phase: &str, config: &PhaseConfig) -> PhaseDeliverables { + // Check if this phase exists in the config + let phase_exists = config.phases.iter().any(|p| p.id == phase); + if !phase_exists { + return PhaseDeliverables { + phase: phase.to_string(), + deliverables: vec![], + requires_repository: false, + requires_tasks: false, + guidance: format!("Phase '{}' is not defined in this contract template", phase), + }; + } + + // Get deliverables for this phase from the config + let deliverables: Vec<Deliverable> = config + .deliverables + .get(phase) + .map(|defs| { + defs.iter() + .map(|d| Deliverable { + id: d.id.clone(), + name: d.name.clone(), + priority: match d.priority.as_str() { + "recommended" => DeliverablePriority::Recommended, + "optional" => DeliverablePriority::Optional, + _ => DeliverablePriority::Required, + }, + description: format!("{} deliverable", d.name), + }) + .collect() + }) + .unwrap_or_default(); + + // Determine if repository is required (typically for execute-like phases) + let requires_repository = phase == "execute" || phase == "plan"; + + // Determine if tasks are required (typically for execute phase) + let requires_tasks = phase == "execute"; + + // Find the phase name for better guidance + let phase_name = config + .phases + .iter() + .find(|p| p.id == phase) + .map(|p| p.name.clone()) + .unwrap_or_else(|| phase.to_string()); + + let guidance = if deliverables.is_empty() { + format!("Complete the {} phase. No specific deliverables are required.", phase_name) + } else { + let deliverable_names: Vec<_> = deliverables.iter().map(|d| d.name.clone()).collect(); + format!( + "Complete the {} phase by producing the following deliverables: {}", + phase_name, + deliverable_names.join(", ") + ) + }; + + PhaseDeliverables { + phase: phase.to_string(), + deliverables, + requires_repository, + requires_tasks, + guidance, + } +} + +/// Get phase deliverables, checking custom config first, then falling back to built-in types +pub fn get_phase_deliverables_with_config( + phase: &str, + contract_type: &str, + phase_config: Option<&PhaseConfig>, +) -> PhaseDeliverables { + // If we have a custom phase config, use it + if let Some(config) = phase_config { + return get_phase_deliverables_from_config(phase, config); + } + + // Otherwise, fall back to built-in contract types + get_phase_deliverables_for_type(phase, contract_type) +} + /// Get deliverables for 'simple' contract type fn get_simple_type_deliverables(phase: &str) -> PhaseDeliverables { match phase { diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index c1ca3ed..a066595 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -2595,6 +2595,7 @@ async fn handle_contract_request( local_only: None, red_team_enabled: None, red_team_prompt: None, + template_id: None, }; let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { diff --git a/makima/src/server/handlers/templates.rs b/makima/src/server/handlers/templates.rs index c73007e..0cc5657 100644 --- a/makima/src/server/handlers/templates.rs +++ b/makima/src/server/handlers/templates.rs @@ -1,11 +1,24 @@ //! Contract types API handler. -use axum::{http::StatusCode, response::IntoResponse, Json}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; use serde::Serialize; use utoipa::ToSchema; +use uuid::Uuid; +use crate::db::models::{ + ContractTypeTemplateRecord, ContractTypeTemplateSummary, CreateTemplateRequest, + UpdateTemplateRequest, +}; +use crate::db::repository; use crate::llm::templates; use crate::llm::templates::ContractTypeTemplate; +use crate::server::auth::{Authenticated, MaybeAuthenticated}; +use crate::server::state::SharedState; // ============================================================================= // Contract Type Templates (Workflow Definitions) @@ -18,7 +31,14 @@ pub struct ListContractTypesResponse { pub contract_types: Vec<ContractTypeTemplate>, } -/// List all available contract type templates +/// Response for a single custom template +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TemplateResponse { + pub template: ContractTypeTemplateSummary, +} + +/// List all available contract type templates (built-in + custom) #[utoipa::path( get, path = "/api/v1/contract-types", @@ -27,8 +47,25 @@ pub struct ListContractTypesResponse { ), tag = "templates" )] -pub async fn list_contract_types() -> impl IntoResponse { - let contract_types = templates::all_contract_types(); +pub async fn list_contract_types( + State(state): State<SharedState>, + MaybeAuthenticated(auth): MaybeAuthenticated, +) -> impl IntoResponse { + // Start with built-in types + let mut contract_types = templates::all_contract_types(); + + // If authenticated, also fetch custom templates for this owner + if let Some(user) = auth { + if let Some(ref pool) = state.db_pool { + if let Ok(custom_templates) = + repository::list_templates_for_owner(pool, user.owner_id).await + { + for template in custom_templates { + contract_types.push(template_record_to_api(&template)); + } + } + } + } ( StatusCode::OK, @@ -36,3 +73,378 @@ pub async fn list_contract_types() -> impl IntoResponse { ) .into_response() } + +/// Create a new custom contract type template +#[utoipa::path( + post, + path = "/api/v1/contract-types", + request_body = CreateTemplateRequest, + responses( + (status = 201, description = "Template created successfully", body = TemplateResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 409, description = "Template with this name already exists") + ), + tag = "templates" +)] +pub async fn create_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateTemplateRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + // Validate request + if req.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": "Template name cannot be empty" + })), + ) + .into_response(); + } + + if req.phases.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": "Template must have at least one phase" + })), + ) + .into_response(); + } + + // Validate default_phase is in the phases list + if !req.phases.iter().any(|p| p.id == req.default_phase) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": format!("Default phase '{}' is not in the phases list", req.default_phase) + })), + ) + .into_response(); + } + + // Check that template name doesn't conflict with built-in types + let builtin_names = ["simple", "specification", "execute"]; + if builtin_names.contains(&req.name.to_lowercase().as_str()) { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "Cannot create a template with the same name as a built-in type" + })), + ) + .into_response(); + } + + match repository::create_template_for_owner(pool, auth.owner_id, req).await { + Ok(template) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "template": template_record_to_summary(&template) + })), + ) + .into_response(), + Err(e) => { + // Check for unique constraint violation + let error_str = e.to_string(); + if error_str.contains("unique") || error_str.contains("duplicate") { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "A template with this name already exists" + })), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to create template: {}", e) + })), + ) + .into_response() + } + } +} + +/// Get a specific contract type template by ID +#[utoipa::path( + get, + path = "/api/v1/contract-types/{id}", + params( + ("id" = Uuid, Path, description = "Template ID") + ), + responses( + (status = 200, description = "Template retrieved successfully", body = TemplateResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found") + ), + tag = "templates" +)] +pub async fn get_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + match repository::get_template_for_owner(pool, id, auth.owner_id).await { + Ok(Some(template)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "template": template_record_to_summary(&template) + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "code": "NOT_FOUND", + "message": "Template not found" + })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to get template: {}", e) + })), + ) + .into_response(), + } +} + +/// Update a contract type template +#[utoipa::path( + put, + path = "/api/v1/contract-types/{id}", + params( + ("id" = Uuid, Path, description = "Template ID") + ), + request_body = UpdateTemplateRequest, + responses( + (status = 200, description = "Template updated successfully", body = TemplateResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found"), + (status = 409, description = "Version conflict") + ), + tag = "templates" +)] +pub async fn update_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateTemplateRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + // Validate phases if provided + if let Some(ref phases) = req.phases { + if phases.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": "Template must have at least one phase" + })), + ) + .into_response(); + } + + // If default_phase is also provided, validate it's in the phases + if let Some(ref default_phase) = req.default_phase { + if !phases.iter().any(|p| &p.id == default_phase) { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "code": "INVALID_REQUEST", + "message": format!("Default phase '{}' is not in the phases list", default_phase) + })), + ) + .into_response(); + } + } + } + + // Check that template name doesn't conflict with built-in types + if let Some(ref name) = req.name { + let builtin_names = ["simple", "specification", "execute"]; + if builtin_names.contains(&name.to_lowercase().as_str()) { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "Cannot rename template to a built-in type name" + })), + ) + .into_response(); + } + } + + match repository::update_template_for_owner(pool, id, auth.owner_id, req).await { + Ok(Some(template)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "template": template_record_to_summary(&template) + })), + ) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "code": "NOT_FOUND", + "message": "Template not found" + })), + ) + .into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "VERSION_CONFLICT", + "message": format!("Version conflict: expected {}, found {}", expected, actual), + "expectedVersion": expected, + "actualVersion": actual + })), + ) + .into_response(), + Err(e) => { + let error_str = e.to_string(); + if error_str.contains("unique") || error_str.contains("duplicate") { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "NAME_CONFLICT", + "message": "A template with this name already exists" + })), + ) + .into_response(); + } + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to update template: {}", e) + })), + ) + .into_response() + } + } +} + +/// Delete a contract type template +#[utoipa::path( + delete, + path = "/api/v1/contract-types/{id}", + params( + ("id" = Uuid, Path, description = "Template ID") + ), + responses( + (status = 204, description = "Template deleted successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Template not found") + ), + tag = "templates" +)] +pub async fn delete_template( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": "Database not configured" + })), + ) + .into_response(); + }; + + match repository::delete_template_for_owner(pool, id, auth.owner_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "code": "NOT_FOUND", + "message": "Template not found" + })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "code": "DB_ERROR", + "message": format!("Failed to delete template: {}", e) + })), + ) + .into_response(), + } +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Convert a database template record to the API template format +fn template_record_to_api(template: &ContractTypeTemplateRecord) -> ContractTypeTemplate { + ContractTypeTemplate { + id: template.id.to_string(), + name: template.name.clone(), + description: template.description.clone().unwrap_or_default(), + phases: template.phases.iter().map(|p| p.id.clone()).collect(), + default_phase: template.default_phase.clone(), + is_builtin: false, + } +} + +/// Convert a database template record to the summary format +fn template_record_to_summary(template: &ContractTypeTemplateRecord) -> ContractTypeTemplateSummary { + ContractTypeTemplateSummary { + id: template.id, + name: template.name.clone(), + description: template.description.clone(), + phases: template.phases.clone(), + default_phase: template.default_phase.clone(), + is_builtin: false, + version: template.version, + created_at: template.created_at, + } +} diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs index 0a6ac7f..920851c 100644 --- a/makima/src/server/handlers/transcript_analysis.rs +++ b/makima/src/server/handlers/transcript_analysis.rs @@ -281,6 +281,7 @@ pub async fn create_contract_from_analysis( local_only: None, red_team_enabled: None, red_team_prompt: None, + template_id: None, }; let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 7c13f08..8456006 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -213,7 +213,16 @@ pub fn make_router(state: SharedState) -> Router { // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (workflow definitions) - .route("/contract-types", get(templates::list_contract_types)) + .route( + "/contract-types", + get(templates::list_contract_types).post(templates::create_template), + ) + .route( + "/contract-types/{id}", + get(templates::get_template) + .put(templates::update_template) + .delete(templates::delete_template), + ) // Settings endpoints .route( "/settings/repository-history", |
