summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-29 02:56:44 +0000
committersoryu <soryu@soryu.co>2026-01-29 02:56:44 +0000
commitf19acd400cc5bbe1fe51c004c50ee90d704240d8 (patch)
treeb7dcfd6926efcafd6eac33e713ebd321ec4284d0
parent7af8561677cfdcfd23d099a25783c7fef51d1ba6 (diff)
downloadsoryu-f19acd400cc5bbe1fe51c004c50ee90d704240d8.tar.gz
soryu-f19acd400cc5bbe1fe51c004c50ee90d704240d8.zip
Fix contract type selection
-rw-r--r--makima/frontend/src/components/templates/TemplateEditor.tsx31
-rw-r--r--makima/frontend/src/lib/api.ts120
-rw-r--r--makima/frontend/src/routes/contracts.tsx46
-rw-r--r--makima/frontend/src/routes/templates.tsx248
-rw-r--r--makima/migrations/20260130000000_create_contract_templates.sql19
-rw-r--r--makima/src/db/models.rs213
-rw-r--r--makima/src/db/repository.rs344
-rw-r--r--makima/src/llm/phase_guidance.rs86
-rw-r--r--makima/src/server/handlers/contract_chat.rs1
-rw-r--r--makima/src/server/handlers/templates.rs420
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs1
-rw-r--r--makima/src/server/mod.rs11
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",