diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 23:56:51 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 23:56:51 +0100 |
| commit | e11759447b1ac00becfb1e979e488f7f9c9cf478 (patch) | |
| tree | f8a58368de3f6dda3f2f5c1af34e869a0e714205 | |
| parent | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (diff) | |
| download | soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.tar.gz soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.zip | |
chore(cleanup): Phase 5 contracts removal + tmp directive + 30-day expiry + scroll fix (#118)
Sweeping cleanup across the surface and the wire. Net: -14k LOC of legacy
contracts code, plus the tmp/scroll/UX fixes the user asked for.
## Sidebar/editor independent scroll
Replace `height: calc(100vh - 80px)` (which assumed an 80px masthead and
quietly clipped or pushed the whole page below the fold when the masthead
was taller) with `h-screen + overflow-hidden` on the page root and proper
`flex-1 min-h-0` sizing on `<main>`. Sidebar and editor pane now manage
their own scroll independently; the page itself never scrolls.
Same fix in /tmp/:taskId.
## tmp directive — real backing for orphans/ephemerals
New migration `20260501100000_tmp_directive_and_clear_orphans.sql`:
* Adds `directives.is_tmp` BOOLEAN NOT NULL DEFAULT false.
* Partial unique index `(owner_id) WHERE is_tmp` — at most ONE tmp
directive per owner.
* Hard-deletes every existing orphan task (`directive_id IS NULL`).
Per the user spec: "ALSO there are TOO MANY old tasks in tmp, we
need to remove all of them as well."
New repository helpers:
* `get_or_create_tmp_directive(pool, owner_id) -> Directive`
INSERT ON CONFLICT DO NOTHING + fallback SELECT, race-safe.
* `list_all_tmp_directives` — drives the expiry sweep.
* `delete_expired_tmp_tasks(tmp_directive_id) -> u64`.
* `list_tmp_tasks_for_owner` (replaces `list_orphan_tasks_for_owner`).
`mesh::create_task`: every top-level task must have a directive. If a
caller doesn't supply `directive_id` and isn't a subtask, attach to the
caller's tmp directive (auto-creating it on first use).
`list_directives_for_owner` filters out `is_tmp=true` so the scratchpad
directive doesn't pollute the contract list — surfaced via the sidebar's
`tmp/` folder instead.
## 30-day expiry on tmp tasks
New `phase_tmp_expiry` in the directive reconciler. Throttled to once per
hour: enumerates every tmp directive, calls `delete_expired_tmp_tasks`,
logs the count. The actual delete is `WHERE created_at < NOW() - INTERVAL
'30 days'` and is fast on the existing index. Subtasks die via FK cascade.
## Phase 5 — contracts removed
### Frontend
Deleted entire `/contracts` surface:
* routes: `contracts.tsx`, `contract-file.tsx`
* components/contracts: ContractList, ContractDetail, ContractCliInput,
ContractContextMenu, CommandModePanel, PhaseBadge, PhaseHint,
PhaseDeliverablesPanel, PhaseProgressBar, QuickActionButtons,
RepositoryPanel, TaskDerivationPreview
* (Kept `PhaseConfirmationModal` — used outside the contracts surface
by `TaskOutput` and `PhaseConfirmationNotification`.)
* Routes deregistered from `main.tsx`; nav entry removed from
`NavStrip`.
### Backend handlers
Deleted: `contracts.rs` (2.4k LOC), `contract_chat.rs` (3.2k LOC),
`contract_daemon.rs` (~940 LOC), `contract_discuss.rs` (~590 LOC),
`transcript_analysis.rs` (~690 LOC). All `/api/v1/contracts/*` routes
deregistered. OpenAPI entries dropped. Module declarations removed from
`server/handlers/mod.rs`.
### CLI
Removed `makima contract` and `makima supervisor` subcommands. Deleted
`daemon/cli/contract.rs` and `daemon/cli/supervisor.rs`. Bin dispatch
trimmed (~377 LOC).
### Orchestrator
Removed the contract-spawn path from `phase_execution`
(`spawn_step_contract` and its caller). `directive_steps.contract_type`
now logs a warning and falls through to standalone-task spawn. Column
itself stays — old data still reads, just no longer triggers a
contract+supervisor spawn.
### TUI
`Action::PerformCreateContract` is now a no-op that surfaces a status
message: "Contracts have been removed. Use directives instead." The TUI
form is dead code pending a wider refresh.
## Out of scope (deliberately left)
* Contracts DB tables (`contracts`, `contract_repositories`,
`contract_chat_history`, `contract_events`, `contract_templates`) are
retained for historical data + because some peripheral code still
joins to them in TaskSummary queries.
* `mesh_supervisor` handlers are retained — they aren't only used by
contracts (some mesh-level supervisor behaviour persists), and the
cross-cutting cleanup is bigger than this PR.
* `directive_steps.contract_type` column itself isn't dropped; just no
longer functional.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 files changed, 275 insertions, 14593 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 6fe4ba9..7c5dad1 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -20,12 +20,9 @@ const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Orders", href: "/orders", requiresAuth: true }, - { - label: "Contracts", - href: "/contracts", - requiresAuth: true, - hideInDocumentMode: true, - }, + // /contracts has been removed in Phase 5; the legacy nav entry is gone. + // /exec is still reachable for the standalone task page but hidden when + // document mode is on (the unified surface routes through /directives). { label: "Exec", href: "/exec", diff --git a/makima/frontend/src/components/contracts/CommandModePanel.tsx b/makima/frontend/src/components/contracts/CommandModePanel.tsx deleted file mode 100644 index b39b309..0000000 --- a/makima/frontend/src/components/contracts/CommandModePanel.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useState, useCallback } from "react"; -import { useNavigate } from "react-router"; -import type { ContractWithRelations } from "../../lib/api"; -import { - getSupervisorStatus, - startSupervisor, - stopSupervisor, - resumeSupervisor, - updateContract, - type SupervisorStatus, -} from "../../lib/api"; - -interface CommandModePanelProps { - contract: ContractWithRelations; - onUpdate: () => void; -} - -const statusConfig: Record< - SupervisorStatus["status"], - { label: string; color: string; bgColor: string } -> = { - not_configured: { - label: "Not Configured", - color: "text-[#555]", - bgColor: "bg-[#555]/10", - }, - pending: { - label: "Ready", - color: "text-yellow-400", - bgColor: "bg-yellow-400/10", - }, - starting: { - label: "Starting...", - color: "text-blue-400", - bgColor: "bg-blue-400/10", - }, - running: { - label: "Running", - color: "text-green-400", - bgColor: "bg-green-400/10", - }, - paused: { - label: "Paused", - color: "text-orange-400", - bgColor: "bg-orange-400/10", - }, - done: { - label: "Completed", - color: "text-blue-400", - bgColor: "bg-blue-400/10", - }, - failed: { - label: "Failed", - color: "text-red-400", - bgColor: "bg-red-400/10", - }, -}; - -export function CommandModePanel({ contract, onUpdate }: CommandModePanelProps) { - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - - const supervisorStatus = getSupervisorStatus(contract); - - const handleGoToSupervisor = useCallback(() => { - if (supervisorStatus.supervisorTaskId) { - navigate(`/exec/${supervisorStatus.supervisorTaskId}`); - } - }, [supervisorStatus.supervisorTaskId, navigate]); - const config = statusConfig[supervisorStatus.status]; - - const handleStart = useCallback(async () => { - if (!supervisorStatus.supervisorTaskId) return; - - setLoading(true); - setError(null); - - try { - await startSupervisor(supervisorStatus.supervisorTaskId); - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to start command mode"); - } finally { - setLoading(false); - } - }, [supervisorStatus.supervisorTaskId, onUpdate]); - - const handleStop = useCallback(async () => { - if (!supervisorStatus.supervisorTaskId) return; - - setLoading(true); - setError(null); - - try { - await stopSupervisor(supervisorStatus.supervisorTaskId); - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to stop command mode"); - } finally { - setLoading(false); - } - }, [supervisorStatus.supervisorTaskId, onUpdate]); - - const handleResume = useCallback(async () => { - setLoading(true); - setError(null); - - try { - await resumeSupervisor(contract.id, { resumeMode: "continue" }); - // After resuming, we need to start the task - if (supervisorStatus.supervisorTaskId) { - await startSupervisor(supervisorStatus.supervisorTaskId); - } - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to resume command mode"); - } finally { - setLoading(false); - } - }, [contract.id, supervisorStatus.supervisorTaskId, onUpdate]); - - const handlePhaseGuardChange = useCallback(async (enabled: boolean) => { - setLoading(true); - setError(null); - - try { - await updateContract(contract.id, { - phaseGuard: enabled, - version: contract.version, - }); - onUpdate(); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to update phase guard setting"); - } finally { - setLoading(false); - } - }, [contract.id, contract.version, onUpdate]); - - return ( - <div className="space-y-3"> - <div className="flex items-center justify-between"> - <h3 className="font-mono text-xs text-[#75aafc] uppercase"> - Command Mode - </h3> - <div className="flex items-center gap-2"> - {supervisorStatus.supervisorTaskId && ( - <button - onClick={handleGoToSupervisor} - className="px-2 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors flex items-center gap-1" - > - <span className="text-[#75aafc]">▶</span> - Supervisor - </button> - )} - <div - className={`px-2 py-1 rounded font-mono text-xs ${config.color} ${config.bgColor}`} - > - {config.label} - </div> - </div> - </div> - - <p className="font-mono text-xs text-[#555]"> - {supervisorStatus.status === "not_configured" ? ( - "This contract does not have a Command Mode supervisor configured." - ) : supervisorStatus.status === "running" ? ( - "Command Mode is actively working on this contract, spawning tasks and managing progress." - ) : supervisorStatus.status === "pending" ? ( - "Command Mode is ready to start. Click 'Enable' to begin autonomous work." - ) : supervisorStatus.status === "paused" ? ( - "Command Mode is paused. Click 'Resume' to continue work." - ) : supervisorStatus.status === "failed" ? ( - "Command Mode encountered an error. You can resume to retry." - ) : supervisorStatus.status === "done" ? ( - "Command Mode has completed its work on this contract." - ) : ( - "Command Mode is initializing..." - )} - </p> - - {error && ( - <div className="px-3 py-2 bg-red-500/10 border border-red-400/30 font-mono text-xs text-red-400"> - {error} - </div> - )} - - <div className="flex gap-2"> - {supervisorStatus.canStart && ( - <button - onClick={handleStart} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-green-600/20 border border-green-400/50 hover:bg-green-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Starting..." : "Enable Command Mode"} - </button> - )} - - {supervisorStatus.canResume && ( - <button - onClick={handleResume} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-blue-600/20 border border-blue-400/50 hover:bg-blue-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Resuming..." : "Resume Command Mode"} - </button> - )} - - {supervisorStatus.canStop && ( - <button - onClick={handleStop} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-orange-600/20 border border-orange-400/50 hover:bg-orange-600/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Stopping..." : "Pause Command Mode"} - </button> - )} - </div> - - {/* Phase Guard Toggle */} - <div className="pt-3 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <label className="flex items-start gap-3 cursor-pointer group"> - <div className="relative mt-0.5"> - <input - type="checkbox" - checked={contract.phaseGuard ?? false} - onChange={(e) => handlePhaseGuardChange(e.target.checked)} - disabled={loading} - className="sr-only peer" - /> - <div className="w-9 h-5 bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] rounded-full peer-checked:bg-[rgba(117,170,252,0.3)] transition-colors peer-disabled:opacity-50" /> - <div className="absolute left-0.5 top-0.5 w-4 h-4 bg-[#555] rounded-full transition-transform peer-checked:translate-x-4 peer-checked:bg-[#75aafc] peer-disabled:opacity-50" /> - </div> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <span className="font-mono text-sm text-[#dbe7ff] group-hover:text-white transition-colors"> - Phase Guard - </span> - {contract.phaseGuard && ( - <span className="px-1.5 py-0.5 text-[9px] font-mono uppercase bg-yellow-500/20 text-yellow-400 border border-yellow-400/30 rounded"> - active - </span> - )} - </div> - <div className="font-mono text-xs text-[#555] mt-0.5"> - Ask for confirmation before advancing to the next phase - </div> - </div> - </label> - </div> - - {/* Show running indicator when active */} - {supervisorStatus.status === "running" && ( - <div className="flex items-center gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" /> - <span className="font-mono text-xs text-green-400"> - Command Mode is actively working - </span> - </div> - )} - - {supervisorStatus.status === "starting" && ( - <div className="flex items-center gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" /> - <span className="font-mono text-xs text-blue-400"> - Initializing command mode... - </span> - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx deleted file mode 100644 index 54d9f3a..0000000 --- a/makima/frontend/src/components/contracts/ContractCliInput.tsx +++ /dev/null @@ -1,974 +0,0 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from "react"; -import { - getContractChatHistory, - clearContractChatHistory, - startTask, - sendTaskMessage, - type UserQuestion, - type ContractWithRelations, - type TaskStatus, -} from "../../lib/api"; -import { SimpleMarkdown } from "../SimpleMarkdown"; -import { - QuickActionButtons, - type QuickAction, -} from "./QuickActionButtons"; -import { TaskDerivationPreview, type ParsedTask } from "./TaskDerivationPreview"; -import { useTaskSubscription, type TaskOutputEvent } from "../../hooks/useTaskSubscription"; - -interface ContractCliInputProps { - contractId: string; - contract: ContractWithRelations; - onUpdate: () => void; -} - -interface Message { - id: string; - type: "user" | "assistant" | "error" | "question"; - content: string; - toolCalls?: { name: string; success: boolean; message: string }[]; - questions?: UserQuestion[]; - quickActions?: QuickAction[]; -} - -export function ContractCliInput({ contractId, contract, onUpdate }: ContractCliInputProps) { - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [historyLoading, setHistoryLoading] = useState(true); - const [messages, setMessages] = useState<Message[]>([]); - const [expanded, setExpanded] = useState(false); - const [fullscreen, setFullscreen] = useState(false); - const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null); - const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map()); - const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map()); - - // Task derivation state - const [parsedTasks, setParsedTasks] = useState<ParsedTask[] | null>(null); - const [parsedTaskGroups, setParsedTaskGroups] = useState<string[]>([]); - const [parsedTasksFileName, setParsedTasksFileName] = useState<string>(""); - const [creatingTasks, setCreatingTasks] = useState(false); - - // Supervisor state - const [supervisorStarting, setSupervisorStarting] = useState(false); - const [supervisorOutput, setSupervisorOutput] = useState<TaskOutputEvent[]>([]); - const [supervisorQuestion, setSupervisorQuestion] = useState<{ - id: string; - question: string; - options: string[]; - allowMultiple?: boolean; - allowCustom?: boolean; - } | null>(null); - - const inputRef = useRef<HTMLInputElement>(null); - const messagesRef = useRef<HTMLDivElement>(null); - - // Find the supervisor task for this contract - // First try by supervisorTaskId on the contract, then fall back to isSupervisor flag - const supervisorTask = useMemo(() => { - // Use contract.supervisorTaskId if available (most reliable) - if (contract.supervisorTaskId) { - const taskById = contract.tasks.find((t) => t.id === contract.supervisorTaskId); - if (taskById) return taskById; - } - // Fallback to finding by isSupervisor flag - return contract.tasks.find((t) => t.isSupervisor); - }, [contract.tasks, contract.supervisorTaskId]); - - // Log for debugging - useEffect(() => { - console.log("Supervisor lookup:", { - contractId: contract.id, - supervisorTaskId: contract.supervisorTaskId, - tasksCount: contract.tasks.length, - foundSupervisor: supervisorTask ? { id: supervisorTask.id, status: supervisorTask.status, isSupervisor: supervisorTask.isSupervisor } : null, - allTasks: contract.tasks.map(t => ({ id: t.id, name: t.name, isSupervisor: t.isSupervisor })) - }); - }, [contract.id, contract.supervisorTaskId, contract.tasks, supervisorTask]); - - const supervisorTaskId = supervisorTask?.id ?? null; - const supervisorStatus = supervisorTask?.status as TaskStatus | undefined; - const isSupervisorRunning = supervisorStatus === "running"; - const isSupervisorPending = supervisorStatus === "pending"; - - // Subscribe to supervisor output when it's running - const handleSupervisorOutput = useCallback((event: TaskOutputEvent) => { - // Check for question pattern in output - // Pattern: {"__supervisor_question__": {"id": "...", "question": "...", "options": [...]}} - if (!event.isPartial && event.content) { - const questionMatch = event.content.match(/\{"__supervisor_question__":\s*(\{[^}]+\})\}/); - if (questionMatch) { - try { - const questionData = JSON.parse(questionMatch[1]); - if (questionData.id && questionData.question && questionData.options) { - setSupervisorQuestion({ - id: questionData.id, - question: questionData.question, - options: questionData.options, - allowMultiple: questionData.allowMultiple ?? false, - allowCustom: questionData.allowCustom ?? true, - }); - // Don't add this to output since it's a control message - return; - } - } catch { - // Not valid JSON, continue as normal output - } - } - } - - setSupervisorOutput((prev) => { - // If it's a partial message, update the last message - if (event.isPartial && prev.length > 0) { - const lastEvent = prev[prev.length - 1]; - if (lastEvent.messageType === event.messageType && lastEvent.isPartial) { - return [...prev.slice(0, -1), { ...event, content: lastEvent.content + event.content }]; - } - } - return [...prev, event]; - }); - }, []); - - useTaskSubscription({ - taskId: supervisorTaskId, - subscribeOutput: isSupervisorRunning, - onOutput: handleSupervisorOutput, - }); - - // Auto-start supervisor function - starts and waits for it to be running - const ensureSupervisorStarted = useCallback(async (): Promise<boolean> => { - if (!supervisorTask) { - console.warn("No supervisor task found for contract"); - return false; - } - - if (isSupervisorRunning) { - return true; // Already running - } - - if (isSupervisorPending) { - try { - setSupervisorStarting(true); - await startTask(supervisorTask.id); - - // Poll for the task to be running (up to 10 seconds) - for (let i = 0; i < 20; i++) { - await new Promise(resolve => setTimeout(resolve, 500)); - onUpdate(); // Refresh contract to get updated task status - // Note: We can't check the new status here directly since state updates are async - // The UI will update when onUpdate triggers a re-render - } - - // Return true - the caller should check if supervisor is running after this - return true; - } catch (err) { - console.error("Failed to start supervisor:", err); - return false; - } finally { - setSupervisorStarting(false); - } - } - - // Supervisor exists but is in some other state (paused, done, failed, etc.) - // Can still send messages to paused tasks - return supervisorStatus === "paused"; - }, [supervisorTask, isSupervisorRunning, isSupervisorPending, supervisorStatus, onUpdate]); - - // Handle answering supervisor questions - const [supervisorAnswers, setSupervisorAnswers] = useState<string[]>([]); - const [supervisorCustomInput, setSupervisorCustomInput] = useState(""); - - const handleSupervisorOptionToggle = useCallback((option: string) => { - setSupervisorAnswers((prev) => { - if (supervisorQuestion?.allowMultiple) { - if (prev.includes(option)) { - return prev.filter((a) => a !== option); - } - return [...prev, option]; - } - return [option]; - }); - }, [supervisorQuestion?.allowMultiple]); - - const handleSubmitSupervisorAnswer = useCallback(async () => { - if (!supervisorQuestion || !supervisorTask) return; - - const customAnswer = supervisorCustomInput.trim(); - const allAnswers = customAnswer - ? [...supervisorAnswers, customAnswer] - : supervisorAnswers; - - if (allAnswers.length === 0) return; - - // Format answer message for supervisor - const answerMessage = `__supervisor_answer__ ${JSON.stringify({ - id: supervisorQuestion.id, - answers: allAnswers, - })}`; - - try { - await sendTaskMessage(supervisorTask.id, answerMessage); - - // Add user message to chat - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { - id: userMsgId, - type: "user", - content: `[Answer to: ${supervisorQuestion.question}]\n${allAnswers.join(", ")}`, - }, - ]); - } catch (err) { - console.error("Failed to send supervisor answer:", err); - } finally { - setSupervisorQuestion(null); - setSupervisorAnswers([]); - setSupervisorCustomInput(""); - } - }, [supervisorQuestion, supervisorTask, supervisorAnswers, supervisorCustomInput]); - - const handleCancelSupervisorQuestion = useCallback(() => { - setSupervisorQuestion(null); - setSupervisorAnswers([]); - setSupervisorCustomInput(""); - }, []); - - // Load chat history on mount - useEffect(() => { - let mounted = true; - - async function loadHistory() { - try { - const history = await getContractChatHistory(contractId); - if (!mounted) return; - - // Convert saved messages to display messages - const displayMessages: Message[] = history.messages.map((msg) => ({ - id: msg.id, - type: msg.role as "user" | "assistant" | "error", - content: msg.content, - toolCalls: msg.toolCalls as { name: string; success: boolean; message: string }[] | undefined, - })); - - setMessages(displayMessages); - - // Auto-expand if there's history - if (displayMessages.length > 0) { - setExpanded(true); - } - } catch (err) { - console.error("Failed to load contract chat history:", err); - } finally { - if (mounted) { - setHistoryLoading(false); - } - } - } - - loadHistory(); - - return () => { - mounted = false; - }; - }, [contractId]); - - // Auto-scroll to bottom when messages change - useEffect(() => { - if (messagesRef.current) { - messagesRef.current.scrollTop = messagesRef.current.scrollHeight; - } - }, [messages]); - - // Auto-start supervisor when component mounts if it's pending (but not for completed contracts) - useEffect(() => { - if (supervisorTask && isSupervisorPending && !supervisorStarting && contract.status !== 'completed') { - console.log("Auto-starting supervisor task on mount..."); - ensureSupervisorStarted().then((started) => { - if (started) { - console.log("Supervisor started successfully"); - } - }); - } - }, [supervisorTask?.id, contract.status]); // Only run when task ID or contract status changes - - // Convert supervisor output events to messages - useEffect(() => { - if (supervisorOutput.length === 0) return; - - // Get the latest event - const latestEvent = supervisorOutput[supervisorOutput.length - 1]; - - // Only add complete messages (not partials) to the message history - if (!latestEvent.isPartial && latestEvent.content.trim()) { - const msgId = `supervisor-${Date.now()}`; - let msgType: "assistant" | "error" = "assistant"; - let content = latestEvent.content; - - // Format based on message type - switch (latestEvent.messageType) { - case "assistant": - content = latestEvent.content; - break; - case "tool_use": - content = `_Using tool: ${latestEvent.toolName}_`; - break; - case "tool_result": - content = latestEvent.isError - ? `Tool error: ${latestEvent.content}` - : `Tool result: ${latestEvent.content.slice(0, 200)}${latestEvent.content.length > 200 ? "..." : ""}`; - msgType = latestEvent.isError ? "error" : "assistant"; - break; - case "error": - msgType = "error"; - break; - case "result": - // Final result - show cost info if available - if (latestEvent.costUsd) { - content = `${latestEvent.content}\n\n_Cost: $${latestEvent.costUsd.toFixed(4)}_`; - } - break; - default: - // system, raw, etc. - break; - } - - setMessages((prev) => { - // Don't add duplicate messages - if (prev.some((m) => m.content === content)) return prev; - return [ - ...prev, - { id: msgId, type: msgType, content }, - ]; - }); - } - }, [supervisorOutput]); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!input.trim() || loading) return; - - const userMessage = input.trim(); - setInput(""); - setExpanded(true); - - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { id: userMsgId, type: "user", content: userMessage }, - ]); - - setLoading(true); - - try { - // Supervisor is the ONLY way to interact with contracts - if (!supervisorTask) { - throw new Error("No supervisor task found. Please create a contract with a supervisor."); - } - - // Ensure supervisor is started (this will start it if pending) - await ensureSupervisorStarted(); - - // Send message to supervisor task stdin - await sendTaskMessage(supervisorTask.id, userMessage); - - // Response will come through WebSocket subscription - // No need for a placeholder message - output will stream in - } catch (err) { - const errorMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: errorMsgId, - type: "error", - content: err instanceof Error ? err.message : "An error occurred", - }, - ]); - } finally { - setLoading(false); - inputRef.current?.focus(); - } - }, - [input, loading, supervisorTask, ensureSupervisorStarted] - ); - - const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { - setUserAnswers((prev) => { - const newMap = new Map(prev); - const currentAnswers = newMap.get(questionId) || []; - - if (allowMultiple) { - if (currentAnswers.includes(option)) { - newMap.set(questionId, currentAnswers.filter((a) => a !== option)); - } else { - newMap.set(questionId, [...currentAnswers, option]); - } - } else { - newMap.set(questionId, [option]); - } - - return newMap; - }); - }, []); - - const handleCustomInputChange = useCallback((questionId: string, value: string) => { - setCustomInputs((prev) => { - const newMap = new Map(prev); - newMap.set(questionId, value); - return newMap; - }); - }, []); - - const handleSubmitAnswers = useCallback(async () => { - if (!pendingQuestions || loading) return; - - const answers = pendingQuestions.map((q) => { - const selectedOptions = userAnswers.get(q.id) || []; - const customInput = customInputs.get(q.id)?.trim(); - const finalAnswers = customInput - ? [...selectedOptions, customInput] - : selectedOptions; - - return { - id: q.id, - answers: finalAnswers, - }; - }); - - const answerText = answers - .map((a) => { - const question = pendingQuestions.find((q) => q.id === a.id); - return `${question?.question || a.id}: ${a.answers.join(", ")}`; - }) - .join("\n"); - - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, - ]); - - setLoading(true); - - try { - if (!supervisorTask) { - throw new Error("No supervisor task found"); - } - await ensureSupervisorStarted(); - await sendTaskMessage(supervisorTask.id, answerText); - // Response will come through WebSocket - } catch (err) { - const errorMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: errorMsgId, - type: "error", - content: err instanceof Error ? err.message : "An error occurred", - }, - ]); - } finally { - setLoading(false); - } - }, [pendingQuestions, userAnswers, customInputs, loading, supervisorTask, ensureSupervisorStarted]); - - const handleCancelQuestions = useCallback(() => { - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - }, []); - - const clearMessages = useCallback(() => { - setMessages([]); - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - setParsedTasks(null); - setParsedTaskGroups([]); - setParsedTasksFileName(""); - setSupervisorOutput([]); - setSupervisorQuestion(null); - setSupervisorAnswers([]); - setSupervisorCustomInput(""); - }, []); - - // Handle creating tasks from the preview - const handleCreateDerivedTasks = useCallback( - async (selectedTasks: ParsedTask[]) => { - if (selectedTasks.length === 0) { - setParsedTasks(null); - return; - } - - setCreatingTasks(true); - - // Build a message asking the supervisor to create these tasks - const taskList = selectedTasks - .map((t, i) => `${i + 1}. ${t.name}${t.description ? `: ${t.description}` : ""}`) - .join("\n"); - - const message = `Create these ${selectedTasks.length} tasks as chained tasks:\n${taskList}`; - - // Add user message - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { id: userMsgId, type: "user", content: message }, - ]); - - try { - if (!supervisorTask) { - throw new Error("No supervisor task found"); - } - await ensureSupervisorStarted(); - await sendTaskMessage(supervisorTask.id, message); - // Response will come through WebSocket - } catch (err) { - const errorMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: errorMsgId, - type: "error", - content: err instanceof Error ? err.message : "An error occurred", - }, - ]); - } finally { - setCreatingTasks(false); - setParsedTasks(null); - setParsedTaskGroups([]); - setParsedTasksFileName(""); - } - }, - [supervisorTask, ensureSupervisorStarted] - ); - - const handleCancelTaskDerivation = useCallback(() => { - setParsedTasks(null); - setParsedTaskGroups([]); - setParsedTasksFileName(""); - }, []); - - const handleQuickAction = useCallback( - async (action: QuickAction) => { - // Convert the action into a chat message that triggers the appropriate behavior - let message = ""; - switch (action.type) { - case "create_file": - message = "Create the suggested file from the template."; - break; - case "create_task": - message = "Yes, create the tasks."; - break; - case "derive_tasks": - message = "Show me the tasks to review and create them."; - break; - case "run_task": - message = "Run the next task."; - break; - case "advance_phase": - if (action.data?.phase) { - message = `Advance to the ${action.data.phase} phase.`; - } else { - message = "Advance to the next phase."; - } - break; - case "update_file": - message = "Update the file with the task output."; - break; - default: - return; - } - - setExpanded(true); - - // Submit the message - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { id: userMsgId, type: "user", content: message }, - ]); - - setLoading(true); - try { - if (!supervisorTask) { - throw new Error("No supervisor task found"); - } - await ensureSupervisorStarted(); - await sendTaskMessage(supervisorTask.id, message); - // Response will come through WebSocket - } catch (err) { - const errorMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: errorMsgId, - type: "error", - content: err instanceof Error ? err.message : "An error occurred", - }, - ]); - } finally { - setLoading(false); - } - }, - [supervisorTask, ensureSupervisorStarted] - ); - - return ( - <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> - {/* Header bar with supervisor status and toggle */} - <div className="px-3 py-2 flex items-center justify-between border-b border-[rgba(117,170,252,0.2)]"> - <div className="flex items-center gap-3"> - <span className="font-mono text-[10px] text-[#555] uppercase tracking-wide"> - Supervisor - </span> - {supervisorTask && ( - <span className={`font-mono text-[10px] px-2 py-0.5 border ${ - isSupervisorRunning - ? "text-green-400 border-green-400/30 bg-green-400/10" - : isSupervisorPending || supervisorStarting - ? "text-yellow-400 border-yellow-400/30 bg-yellow-400/10" - : "text-[#555] border-[rgba(117,170,252,0.2)]" - }`}> - {supervisorStarting ? "Starting..." : isSupervisorRunning ? "Running" : supervisorStatus || "Unknown"} - </span> - )} - {!supervisorTask && ( - <span className="font-mono text-[10px] text-red-400"> - No supervisor - </span> - )} - </div> - {messages.length > 0 && ( - <button - type="button" - onClick={() => setExpanded(!expanded)} - className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" - > - {expanded ? "Hide Messages" : `Show Messages (${messages.length})`} - </button> - )} - </div> - - {/* History loading indicator */} - {historyLoading && ( - <div className="px-3 py-2 text-[10px] font-mono text-[#555] flex items-center gap-2 border-b border-[rgba(117,170,252,0.2)]"> - <span className="animate-pulse">Loading history...</span> - </div> - )} - - {/* Messages Panel (expandable) */} - {expanded && messages.length > 0 && !historyLoading && ( - <div className="relative border-b border-[rgba(117,170,252,0.2)]"> - {/* Expand/Collapse button */} - <div className="absolute top-2 right-2 z-10 flex gap-1"> - <button - type="button" - onClick={() => setFullscreen(!fullscreen)} - className="px-2 py-1 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" - title={fullscreen ? "Collapse" : "Expand"} - > - {fullscreen ? "▼ Collapse" : "▲ Expand"} - </button> - </div> - <div - ref={messagesRef} - className={`overflow-y-auto p-3 pr-24 space-y-2 transition-all duration-200 ${ - fullscreen ? "max-h-[60vh]" : "max-h-48" - }`} - > - {messages.map((msg) => ( - <div key={msg.id} className="font-mono text-xs"> - {msg.type === "user" && ( - <div className="flex gap-2"> - <span className="text-[#9bc3ff]">></span> - <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span> - </div> - )} - {(msg.type === "assistant" || msg.type === "question") && ( - <div className="pl-4 space-y-1"> - <SimpleMarkdown content={msg.content} className="text-[#75aafc]" /> - {msg.toolCalls && msg.toolCalls.length > 0 && ( - <div className="text-[#555] text-[10px] space-y-0.5"> - {msg.toolCalls.map((tc, i) => ( - <div key={i}> - <span - className={ - tc.success ? "text-green-500" : "text-red-400" - } - > - {tc.success ? "+" : "x"} - </span>{" "} - {tc.name}: {tc.message} - </div> - ))} - </div> - )} - {msg.quickActions && msg.quickActions.length > 0 && ( - <QuickActionButtons - actions={msg.quickActions} - onAction={handleQuickAction} - loading={loading} - /> - )} - </div> - )} - {msg.type === "error" && ( - <div className="pl-4 text-red-400">{msg.content}</div> - )} - </div> - ))} - </div> - </div> - )} - - {/* Pending Questions UI */} - {pendingQuestions && pendingQuestions.length > 0 && ( - <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3"> - <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide"> - Questions from AI - </div> - {pendingQuestions.map((q) => ( - <div key={q.id} className="space-y-2"> - <div className="text-white/90 font-mono text-sm">{q.question}</div> - <div className="flex flex-wrap gap-2"> - {q.options.map((option) => { - const isSelected = (userAnswers.get(q.id) || []).includes(option); - return ( - <button - key={option} - type="button" - onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)} - className={`px-2 py-1 font-mono text-xs border transition-colors ${ - isSelected - ? "bg-[#3f6fb3] border-[#75aafc] text-white" - : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]" - }`} - > - {q.allowMultiple && ( - <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span> - )} - {option} - </button> - ); - })} - </div> - {q.allowCustom && ( - <input - type="text" - value={customInputs.get(q.id) || ""} - onChange={(e) => handleCustomInputChange(q.id, e.target.value)} - placeholder="Or type a custom answer..." - className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" - /> - )} - </div> - ))} - <div className="flex gap-2 pt-2"> - <button - type="button" - onClick={handleSubmitAnswers} - disabled={loading} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - {loading ? "..." : "Submit Answers"} - </button> - <button - type="button" - onClick={handleCancelQuestions} - disabled={loading} - className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" - > - Cancel - </button> - </div> - </div> - )} - - {/* Supervisor Question UI */} - {supervisorQuestion && ( - <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3 bg-[rgba(117,170,252,0.05)]"> - <div className="text-green-400 font-mono text-xs uppercase tracking-wide flex items-center gap-2"> - <span className="w-2 h-2 rounded-full bg-green-400 animate-pulse" /> - Question from Supervisor - </div> - <div className="text-white/90 font-mono text-sm">{supervisorQuestion.question}</div> - <div className="flex flex-wrap gap-2"> - {supervisorQuestion.options.map((option) => { - const isSelected = supervisorAnswers.includes(option); - return ( - <button - key={option} - type="button" - onClick={() => handleSupervisorOptionToggle(option)} - className={`px-2 py-1 font-mono text-xs border transition-colors ${ - isSelected - ? "bg-green-500/30 border-green-400 text-white" - : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-green-400" - }`} - > - {supervisorQuestion.allowMultiple && ( - <span className="mr-1">{isSelected ? "[x]" : "[ ]"}</span> - )} - {option} - </button> - ); - })} - </div> - {supervisorQuestion.allowCustom && ( - <input - type="text" - value={supervisorCustomInput} - onChange={(e) => setSupervisorCustomInput(e.target.value)} - placeholder="Or type a custom answer..." - className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-green-400 placeholder-[#555]" - /> - )} - <div className="flex gap-2 pt-2"> - <button - type="button" - onClick={handleSubmitSupervisorAnswer} - disabled={supervisorAnswers.length === 0 && !supervisorCustomInput.trim()} - className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/50 hover:border-green-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - Send Answer - </button> - <button - type="button" - onClick={handleCancelSupervisorQuestion} - className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" - > - Dismiss - </button> - </div> - </div> - )} - - {/* Contract Context Badge */} - <div className="px-3 pt-2 pb-1 flex items-center gap-2 text-[10px] font-mono text-[#555]"> - <span className="text-[#75aafc]">{contract.phase}</span> - <span>|</span> - {supervisorTask && ( - <> - <span - className={ - isSupervisorRunning - ? "text-green-400" - : isSupervisorPending - ? "text-yellow-400" - : supervisorStarting - ? "text-cyan-400 animate-pulse" - : "text-[#555]" - } - > - Supervisor: {supervisorStarting ? "starting..." : supervisorTask.status} - </span> - <span>|</span> - </> - )} - <span>{contract.files.length} files</span> - <span>|</span> - <span>{contract.tasks.length} tasks</span> - <span>|</span> - <span>{contract.repositories.length} repos</span> - <span>|</span> - <button - type="button" - onClick={() => { - const prompt = "Guide me to complete this phase and advance to the next. Analyze my current deliverables, identify what's missing, and suggest specific next steps."; - setInput(prompt); - // Auto-submit the prompt - setTimeout(() => { - const form = document.querySelector('form'); - if (form) form.requestSubmit(); - }, 0); - }} - disabled={loading || !!pendingQuestions} - className="text-[#75aafc] hover:text-[#9bc3ff] disabled:opacity-50 transition-colors cursor-pointer" - > - Progress → - </button> - {messages.length > 0 && ( - <> - <span>|</span> - <button - type="button" - onClick={async () => { - if (window.confirm("Clear all chat history for this contract?")) { - try { - await clearContractChatHistory(contractId); - setMessages([]); - } catch (err) { - console.error("Failed to clear history:", err); - } - } - }} - disabled={loading} - className="text-[#555] hover:text-red-400 disabled:opacity-50 transition-colors cursor-pointer" - > - Clear - </button> - </> - )} - </div> - - {/* Input Bar */} - <form onSubmit={handleSubmit} className="flex items-center gap-2 px-3 pb-3"> - <span className="text-[#9bc3ff] font-mono text-sm">></span> - <input - ref={inputRef} - type="text" - value={input} - onChange={(e) => setInput(e.target.value)} - placeholder={ - loading - ? "Processing..." - : supervisorStarting - ? "Starting supervisor..." - : supervisorQuestion - ? "Answer supervisor question above..." - : pendingQuestions - ? "Answer questions above first..." - : isSupervisorRunning - ? "Message supervisor..." - : "Create a task, add a file, or ask about the contract..." - } - disabled={loading || !!pendingQuestions || !!supervisorQuestion} - className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" - /> - {messages.length > 0 && ( - <button - type="button" - onClick={clearMessages} - className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" - > - clear - </button> - )} - <button - type="submit" - disabled={loading || !input.trim() || !!pendingQuestions || !!supervisorQuestion} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - {loading ? "..." : "Send"} - </button> - </form> - - {/* Task Derivation Preview Modal */} - {parsedTasks && parsedTasks.length > 0 && ( - <TaskDerivationPreview - tasks={parsedTasks} - groups={parsedTaskGroups} - fileName={parsedTasksFileName} - onCreateTasks={handleCreateDerivedTasks} - onCancel={handleCancelTaskDerivation} - loading={creatingTasks} - /> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/ContractContextMenu.tsx b/makima/frontend/src/components/contracts/ContractContextMenu.tsx deleted file mode 100644 index f31beb5..0000000 --- a/makima/frontend/src/components/contracts/ContractContextMenu.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { ContractSummary } from "../../lib/api"; - -interface ContractContextMenuProps { - x: number; - y: number; - contract: ContractSummary; - onClose: () => void; - onMarkComplete: () => void; - onMarkActive: () => void; - onArchive: () => void; - onDelete: () => void; - onGoToSupervisor: () => void; -} - -export function ContractContextMenu({ - x, - y, - contract, - onClose, - onMarkComplete, - onMarkActive, - onArchive, - onDelete, - onGoToSupervisor, -}: ContractContextMenuProps) { - const menuRef = useRef<HTMLDivElement>(null); - - // Close on click outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKeyDown); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [onClose]); - - // Adjust position if menu would overflow viewport - useEffect(() => { - if (menuRef.current) { - const rect = menuRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (rect.right > viewportWidth) { - menuRef.current.style.left = `${x - rect.width}px`; - } - if (rect.bottom > viewportHeight) { - menuRef.current.style.top = `${y - rect.height}px`; - } - } - }, [x, y]); - - const menuItemClass = - "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; - const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; - - const showMarkComplete = contract.status !== "completed"; - const showMarkActive = contract.status !== "active"; - const showArchive = contract.status !== "archived"; - const showGoToSupervisor = !!contract.supervisorTaskId; - - return ( - <div - ref={menuRef} - className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]" - style={{ left: x, top: y }} - > - {/* Header showing contract name */} - <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)] truncate max-w-[200px]"> - {contract.name} - </div> - - {/* Status actions */} - {showMarkComplete && ( - <button - className={menuItemClass} - onClick={() => { - onMarkComplete(); - onClose(); - }} - > - <span className="text-[#75aafc]">✓</span> - Mark as Complete - </button> - )} - - {showMarkActive && ( - <button - className={menuItemClass} - onClick={() => { - onMarkActive(); - onClose(); - }} - > - <span className="text-[#75aafc]">●</span> - Mark as Active - </button> - )} - - {showArchive && ( - <button - className={menuItemClass} - onClick={() => { - onArchive(); - onClose(); - }} - > - <span className="text-[#75aafc]">▣</span> - Archive - </button> - )} - - {/* Supervisor link */} - {showGoToSupervisor && ( - <> - <div className={dividerClass} /> - <button - className={menuItemClass} - onClick={() => { - onGoToSupervisor(); - onClose(); - }} - > - <span className="text-[#75aafc]">▶</span> - Go to Supervisor Task - </button> - </> - )} - - <div className={dividerClass} /> - - {/* Delete action */} - <button - className={`${menuItemClass} text-red-400 hover:bg-red-400/10`} - onClick={() => { - onDelete(); - onClose(); - }} - > - <span className="text-red-400">✕</span> - Delete - </button> - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx deleted file mode 100644 index 02c129e..0000000 --- a/makima/frontend/src/components/contracts/ContractDetail.tsx +++ /dev/null @@ -1,810 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import type { - ContractWithRelations, - ContractPhase, - ContractStatus, - ContractRepository, - FileSummary, - TaskSummary, - TemplateSummary, -} from "../../lib/api"; -import { - listTemplates, - getTemplate, - createFile, -} from "../../lib/api"; -import { PhaseProgressBar } from "./PhaseProgressBar"; -import { PhaseHint } from "./PhaseHint"; -import { RepositoryPanel } from "./RepositoryPanel"; -import { ContractCliInput } from "./ContractCliInput"; -import { PhaseDeliverablesPanel } from "./PhaseDeliverablesPanel"; -import { CommandModePanel } from "./CommandModePanel"; -import { TaskTree } from "../mesh/TaskTree"; - -type Tab = "overview" | "repos" | "files" | "tasks"; - -interface ContractDetailProps { - contract: ContractWithRelations; - loading: boolean; - onBack: () => void; - onUpdate: (name: string, description: string) => void; - onDelete: () => void; - onPhaseChange: (phase: ContractPhase) => void; - onStatusChange: (status: ContractStatus) => void; - onFileSelect: (id: string) => void; - onTaskSelect: (id: string) => void; - onTaskCreate: (name: string, plan: string, repositoryUrl?: string) => void; - onRefresh: () => void; - // Repository callbacks - onAddRemoteRepo: (name: string, url: string, isPrimary: boolean) => void; - onAddLocalRepo: (name: string, path: string, isPrimary: boolean) => void; - onCreateManagedRepo: (name: string, isPrimary: boolean) => void; - onDeleteRepo: (repoId: string) => void; - onSetRepoPrimary: (repoId: string) => void; - // File creation callback for phase deliverables - onCreateFileFromTemplate?: (templateId: string, suggestedName: string) => void; -} - -const statusConfig: Record<ContractStatus, { label: string; color: string }> = { - active: { label: "Active", color: "text-green-400" }, - completed: { label: "Completed", color: "text-blue-400" }, - archived: { label: "Archived", color: "text-[#555]" }, -}; - -export function ContractDetail({ - contract, - loading, - onBack, - onUpdate, - onDelete, - onPhaseChange, - onStatusChange, - onFileSelect, - onTaskSelect, - onTaskCreate, - onRefresh, - onAddRemoteRepo, - onAddLocalRepo, - onCreateManagedRepo, - onDeleteRepo, - onSetRepoPrimary, - onCreateFileFromTemplate, -}: ContractDetailProps) { - const [activeTab, setActiveTab] = useState<Tab>("overview"); - const [isEditing, setIsEditing] = useState(false); - const [name, setName] = useState(contract.name); - const [description, setDescription] = useState(contract.description || ""); - - const handleSave = () => { - onUpdate(name, description); - setIsEditing(false); - }; - - const handleCancel = () => { - setName(contract.name); - setDescription(contract.description || ""); - setIsEditing(false); - }; - - if (loading) { - return ( - <div className="panel h-full flex items-center justify-center"> - <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> - </div> - ); - } - - const tabs: { key: Tab; label: string; count?: number }[] = [ - { key: "overview", label: "Overview" }, - { key: "repos", label: "Repositories", count: contract.repositories.length }, - { key: "files", label: "Files", count: contract.files.length }, - { key: "tasks", label: "Tasks", count: contract.tasks.length }, - ]; - - return ( - <div className="panel h-full flex flex-col min-h-0"> - {/* Header */} - <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> - <div className="flex items-center justify-between mb-3"> - <button - onClick={onBack} - className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" - > - ← Back to list - </button> - <div className="flex items-center gap-2"> - {isEditing ? ( - <> - <button - onClick={handleCancel} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" - > - Cancel - </button> - <button - onClick={handleSave} - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - Save - </button> - </> - ) : ( - <> - <button - onClick={() => setIsEditing(true)} - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase" - > - Edit - </button> - <button - onClick={onDelete} - className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase" - > - Delete - </button> - </> - )} - </div> - </div> - - {isEditing ? ( - <div className="space-y-3"> - <input - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - placeholder="Contract name" - /> - <textarea - value={description} - onChange={(e) => setDescription(e.target.value)} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" - rows={2} - placeholder="Description (optional)" - /> - </div> - ) : ( - <> - <div className="flex items-center gap-3 mb-2"> - <h2 className="font-mono text-lg text-[#dbe7ff]"> - {contract.name} - </h2> - <span - className={`font-mono text-xs uppercase ${ - statusConfig[contract.status].color - }`} - > - {statusConfig[contract.status].label} - </span> - {contract.localOnly && ( - <span className="px-2 py-0.5 font-mono text-[10px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10"> - Local-Only - </span> - )} - </div> - {contract.description && ( - <p className="font-mono text-sm text-[#9bc3ff] mb-3"> - {contract.description} - </p> - )} - </> - )} - - {/* Phase progress */} - <div className="mt-4 pt-4 border-t border-dashed border-[rgba(117,170,252,0.2)]"> - <PhaseProgressBar - currentPhase={contract.phase} - contractType={contract.contractType} - onPhaseClick={onPhaseChange} - /> - </div> - </div> - - {/* Tabs */} - <div className="flex border-b border-[rgba(117,170,252,0.2)]"> - {tabs.map((tab) => ( - <button - key={tab.key} - onClick={() => setActiveTab(tab.key)} - className={` - px-4 py-2 font-mono text-xs uppercase tracking-wider transition-colors - ${ - activeTab === tab.key - ? "text-[#dbe7ff] border-b-2 border-[#75aafc]" - : "text-[#555] hover:text-[#9bc3ff]" - } - `} - > - {tab.label} - {tab.count !== undefined && tab.count > 0 && ( - <span className="ml-1 text-[10px]">({tab.count})</span> - )} - </button> - ))} - </div> - - {/* Tab content */} - <div className="flex-1 overflow-y-auto p-4 min-h-0"> - {activeTab === "overview" && ( - <OverviewTab - contract={contract} - onStatusChange={onStatusChange} - onPhaseChange={onPhaseChange} - onCreateFile={onCreateFileFromTemplate} - onRefresh={onRefresh} - /> - )} - - {activeTab === "repos" && ( - <RepositoryPanel - repositories={contract.repositories} - onAddRemote={onAddRemoteRepo} - onAddLocal={onAddLocalRepo} - onCreateManaged={onCreateManagedRepo} - onDelete={onDeleteRepo} - onSetPrimary={onSetRepoPrimary} - /> - )} - - {activeTab === "files" && ( - <FilesTab - files={contract.files} - contractId={contract.id} - contractPhase={contract.phase} - onSelect={onFileSelect} - onRefresh={onRefresh} - /> - )} - - {activeTab === "tasks" && ( - <TasksTab - tasks={contract.tasks} - repositories={contract.repositories} - supervisorTaskId={contract.supervisorTaskId} - contractType={contract.contractType} - onSelect={onTaskSelect} - onCreate={onTaskCreate} - /> - )} - </div> - - {/* Chat Input */} - <ContractCliInput - contractId={contract.id} - contract={contract} - onUpdate={onRefresh} - /> - </div> - ); -} - -// Overview tab -function OverviewTab({ - contract, - onStatusChange, - onPhaseChange, - onCreateFile, - onRefresh, -}: { - contract: ContractWithRelations; - onStatusChange: (status: ContractStatus) => void; - onPhaseChange: (phase: ContractPhase) => void; - onCreateFile?: (templateId: string, suggestedName: string) => void; - onRefresh: () => void; -}) { - return ( - <div className="space-y-6"> - {/* Command Mode controls */} - <CommandModePanel contract={contract} onUpdate={onRefresh} /> - - {/* Phase deliverables checklist */} - <PhaseDeliverablesPanel - contract={contract} - onCreateFile={onCreateFile} - /> - - {/* Phase hint */} - <PhaseHint contract={contract} onAdvancePhase={onPhaseChange} /> - - {/* Task progress summary */} - <TaskStatusSummary tasks={contract.tasks} /> - - {/* Stats */} - <div className="grid grid-cols-3 gap-4"> - <StatCard label="Repositories" value={contract.repositories.length} /> - <StatCard label="Files" value={contract.files.length} /> - <StatCard label="Tasks" value={contract.tasks.length} /> - </div> - - {/* Status change */} - <div> - <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> - Status - </h3> - <div className="flex gap-2"> - {(["active", "completed", "archived"] as ContractStatus[]).map( - (status) => ( - <button - key={status} - onClick={() => onStatusChange(status)} - className={` - px-3 py-1.5 font-mono text-xs uppercase transition-colors - ${ - contract.status === status - ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" - : "text-[#555] border border-transparent hover:text-[#75aafc]" - } - `} - > - {status} - </button> - ) - )} - </div> - </div> - - {/* Metadata */} - <div> - <h3 className="font-mono text-xs text-[#75aafc] uppercase mb-2"> - Details - </h3> - <div className="space-y-1 font-mono text-xs text-[#555]"> - <p>Created: {new Date(contract.createdAt).toLocaleString()}</p> - <p>Updated: {new Date(contract.updatedAt).toLocaleString()}</p> - <p>Version: {contract.version}</p> - </div> - </div> - </div> - ); -} - -function StatCard({ label, value }: { label: string; value: number }) { - return ( - <div className="p-3 border border-[rgba(117,170,252,0.2)]"> - <div className="font-mono text-2xl text-[#dbe7ff]">{value}</div> - <div className="font-mono text-[10px] text-[#555] uppercase">{label}</div> - </div> - ); -} - -// Task status summary with progress bar -function TaskStatusSummary({ tasks }: { tasks: TaskSummary[] }) { - if (tasks.length === 0) return null; - - // Count tasks by status - const statusCounts = { - done: 0, - merged: 0, - running: 0, - pending: 0, - failed: 0, - other: 0, - }; - - for (const task of tasks) { - switch (task.status) { - case "done": - statusCounts.done++; - break; - case "merged": - statusCounts.merged++; - break; - case "running": - case "initializing": - case "starting": - statusCounts.running++; - break; - case "pending": - statusCounts.pending++; - break; - case "failed": - statusCounts.failed++; - break; - default: - statusCounts.other++; - } - } - - const completedCount = statusCounts.done + statusCounts.merged; - const progressPercent = (completedCount / tasks.length) * 100; - - // Build summary parts - const parts: string[] = []; - if (completedCount > 0) parts.push(`${completedCount} done`); - if (statusCounts.running > 0) parts.push(`${statusCounts.running} running`); - if (statusCounts.pending > 0) parts.push(`${statusCounts.pending} pending`); - if (statusCounts.failed > 0) parts.push(`${statusCounts.failed} failed`); - - return ( - <div className="space-y-2"> - <h3 className="font-mono text-xs text-[#75aafc] uppercase"> - Task Progress - </h3> - - {/* Progress bar */} - <div className="h-2 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> - <div - className="h-full bg-green-400 transition-all duration-300" - style={{ width: `${progressPercent}%` }} - /> - </div> - - {/* Summary text */} - <div className="flex items-center justify-between"> - <span className="font-mono text-xs text-[#9bc3ff]"> - {parts.join(", ")} - </span> - <span className="font-mono text-xs text-[#555]"> - {completedCount}/{tasks.length} completed - </span> - </div> - </div> - ); -} - -// Phase color mapping for badges -const phaseColors: Record<ContractPhase, string> = { - research: "bg-purple-500/20 text-purple-400 border-purple-400/30", - specify: "bg-blue-500/20 text-blue-400 border-blue-400/30", - plan: "bg-cyan-500/20 text-cyan-400 border-cyan-400/30", - execute: "bg-green-500/20 text-green-400 border-green-400/30", - review: "bg-yellow-500/20 text-yellow-400 border-yellow-400/30", -}; - -// Files tab with template creation -function FilesTab({ - files, - contractId, - contractPhase, - onSelect, - onRefresh, -}: { - files: FileSummary[]; - contractId: string; - contractPhase: ContractPhase; - onSelect: (id: string) => void; - onRefresh: () => void; -}) { - const [showTemplateModal, setShowTemplateModal] = useState(false); - const [templates, setTemplates] = useState<TemplateSummary[]>([]); - const [loadingTemplates, setLoadingTemplates] = useState(false); - const [creating, setCreating] = useState(false); - const [fileName, setFileName] = useState(""); - const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); - - // Load templates when modal opens - useEffect(() => { - if (showTemplateModal) { - setLoadingTemplates(true); - listTemplates(contractPhase) - .then((res) => setTemplates(res.templates)) - .catch((err) => console.error("Failed to load templates:", err)) - .finally(() => setLoadingTemplates(false)); - } - }, [showTemplateModal, contractPhase]); - - const handleCreateFromTemplate = useCallback(async () => { - if (!fileName.trim() || !selectedTemplateId) return; - - setCreating(true); - try { - // Get the full template with body - const template = await getTemplate(selectedTemplateId); - - // Create the file with contract (files must belong to contracts) - await createFile({ - contractId, - name: fileName.trim(), - description: template.description, - body: template.suggestedBody, - }); - - // Reset and close - setShowTemplateModal(false); - setFileName(""); - setSelectedTemplateId(null); - onRefresh(); - } catch (err) { - console.error("Failed to create file from template:", err); - } finally { - setCreating(false); - } - }, [fileName, selectedTemplateId, contractId, onRefresh]); - - const handleCloseModal = () => { - setShowTemplateModal(false); - setFileName(""); - setSelectedTemplateId(null); - }; - - return ( - <div className="space-y-4"> - {/* Create from template button */} - <button - onClick={() => setShowTemplateModal(true)} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + Create from Template - </button> - - {/* Template Selection Modal */} - {showTemplateModal && ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> - <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> - <div className="flex items-center justify-between mb-4"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase"> - Create File from Template - </h3> - <span className={`px-2 py-0.5 text-[10px] font-mono uppercase border rounded ${phaseColors[contractPhase]}`}> - {contractPhase} phase - </span> - </div> - - <div className="space-y-4 flex-1 overflow-y-auto"> - {/* File name input */} - <div> - <label className="block font-mono text-xs text-[#555] uppercase mb-1"> - File Name - </label> - <input - type="text" - value={fileName} - onChange={(e) => setFileName(e.target.value)} - placeholder="e.g., Project Requirements" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - autoFocus - /> - </div> - - {/* Template selection */} - <div> - <label className="block font-mono text-xs text-[#555] uppercase mb-2"> - Select Template - </label> - {loadingTemplates ? ( - <p className="font-mono text-xs text-[#555]">Loading templates...</p> - ) : templates.length === 0 ? ( - <p className="font-mono text-xs text-[#555]">No templates available for {contractPhase} phase</p> - ) : ( - <div className="space-y-2 max-h-60 overflow-y-auto"> - {templates.map((template) => ( - <button - key={template.id} - onClick={() => setSelectedTemplateId(template.id)} - className={`w-full text-left p-3 border transition-colors ${ - selectedTemplateId === template.id - ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" - : "border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)]" - }`} - > - <div className="flex items-center justify-between mb-1"> - <span className="font-mono text-sm text-[#dbe7ff]"> - {template.name} - </span> - <span className="font-mono text-[10px] text-[#555]"> - {template.elementCount} elements - </span> - </div> - <p className="font-mono text-xs text-[#555]"> - {template.description} - </p> - </button> - ))} - </div> - )} - </div> - </div> - - <div className="flex gap-2 justify-end mt-4 pt-4 border-t border-[rgba(117,170,252,0.2)]"> - <button - onClick={handleCloseModal} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleCreateFromTemplate} - disabled={!fileName.trim() || !selectedTemplateId || creating} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {creating ? "Creating..." : "Create File"} - </button> - </div> - </div> - </div> - )} - - {/* File list */} - {files.length === 0 ? ( - <p className="font-mono text-xs text-[#555]"> - No files in this contract. Create one from a template above. - </p> - ) : ( - <div className="space-y-2"> - {files.map((file) => ( - <button - key={file.id} - onClick={() => onSelect(file.id)} - className="w-full text-left p-3 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" - > - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <span className="font-mono text-sm text-[#dbe7ff]"> - {file.name} - </span> - {file.contractPhase && ( - <span - className={`px-1.5 py-0.5 text-[9px] font-mono uppercase border rounded ${ - phaseColors[file.contractPhase] - }`} - title={`Added during ${file.contractPhase} phase`} - > - {file.contractPhase} - </span> - )} - </div> - <span className="font-mono text-[10px] text-[#555]"> - v{file.version} - </span> - </div> - {file.description && ( - <p className="font-mono text-xs text-[#555] mt-1 truncate"> - {file.description} - </p> - )} - </button> - ))} - </div> - )} - </div> - ); -} - -// Tasks tab - now using TaskTree for supervisor view -function TasksTab({ - tasks, - repositories, - supervisorTaskId, - contractType, - onSelect, - onCreate, -}: { - tasks: TaskSummary[]; - repositories: ContractRepository[]; - supervisorTaskId: string | null; - contractType: string; - onSelect: (id: string) => void; - onCreate: (name: string, plan: string, repositoryUrl?: string) => void; -}) { - const [isCreating, setIsCreating] = useState(false); - const [taskName, setTaskName] = useState(""); - const [taskPlan, setTaskPlan] = useState("# Plan\n\nDescribe what this task should accomplish..."); - - // Find primary repository or first ready one - const readyRepos = repositories.filter((r) => r.status === "ready"); - const primaryRepo = readyRepos.find((r) => r.isPrimary) || readyRepos[0]; - const [selectedRepoId, setSelectedRepoId] = useState<string>(primaryRepo?.id || ""); - - const handleCreate = () => { - if (!taskName.trim()) return; - const selectedRepo = repositories.find((r) => r.id === selectedRepoId); - // Get the URL - for remote repos it's repositoryUrl, for local it's the local path - const repoUrl = selectedRepo?.repositoryUrl || selectedRepo?.localPath; - onCreate(taskName.trim(), taskPlan, repoUrl || undefined); - setIsCreating(false); - setTaskName(""); - setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); - setSelectedRepoId(primaryRepo?.id || ""); - }; - - const handleCancel = () => { - setIsCreating(false); - setTaskName(""); - setTaskPlan("# Plan\n\nDescribe what this task should accomplish..."); - setSelectedRepoId(primaryRepo?.id || ""); - }; - - return ( - <div className="space-y-4"> - {/* TaskTree with supervisor view */} - <TaskTree - tasks={tasks} - supervisorTaskId={supervisorTaskId} - onSelect={onSelect} - /> - - {/* Manual task creation - show for task-type contracts or contracts without supervisors */} - {(contractType === "task" || !supervisorTaskId) && ( - <> - <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> - <button - onClick={() => setIsCreating(true)} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + Create Task Manually - </button> - </div> - - {/* Create Task Modal */} - {isCreating && ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> - <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> - Create Task - </h3> - <div className="space-y-4"> - <div> - <label className="block font-mono text-xs text-[#555] uppercase mb-1"> - Name - </label> - <input - type="text" - value={taskName} - onChange={(e) => setTaskName(e.target.value)} - placeholder="Task name" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - autoFocus - /> - </div> - - {/* Repository selection */} - {readyRepos.length > 0 && ( - <div> - <label className="block font-mono text-xs text-[#555] uppercase mb-1"> - Repository - </label> - <select - value={selectedRepoId} - onChange={(e) => setSelectedRepoId(e.target.value)} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" - > - <option value="">No repository</option> - {readyRepos.map((repo) => ( - <option key={repo.id} value={repo.id}> - {repo.name} - {repo.isPrimary ? " (Primary)" : ""} - {" - "} - {repo.sourceType} - </option> - ))} - </select> - </div> - )} - - <div> - <label className="block font-mono text-xs text-[#555] uppercase mb-1"> - Plan - </label> - <textarea - value={taskPlan} - onChange={(e) => setTaskPlan(e.target.value)} - rows={6} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none" - /> - </div> - - <div className="flex gap-2 justify-end"> - <button - onClick={handleCancel} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleCreate} - disabled={!taskName.trim()} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - </div> - )} - </> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx deleted file mode 100644 index 1eee6a3..0000000 --- a/makima/frontend/src/components/contracts/ContractList.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { useState } from "react"; -import type { ContractSummary, ContractStatus } from "../../lib/api"; -import { PhaseBadge } from "./PhaseBadge"; -import { PhaseProgressBarCompact } from "./PhaseProgressBar"; -import { ContractContextMenu } from "./ContractContextMenu"; - -interface ContractListProps { - contracts: ContractSummary[]; - loading: boolean; - onSelect: (id: string) => void; - onCreate: () => void; - selectedId?: string; - onMarkComplete?: (contract: ContractSummary) => void; - onMarkActive?: (contract: ContractSummary) => void; - onArchive?: (contract: ContractSummary) => void; - onDelete?: (contract: ContractSummary) => void; - onGoToSupervisor?: (contract: ContractSummary) => void; -} - -const statusColors: Record<ContractStatus, string> = { - active: "text-green-400", - completed: "text-blue-400", - archived: "text-[#555]", -}; - -export function ContractList({ - contracts, - loading, - onSelect, - onCreate, - selectedId, - onMarkComplete, - onMarkActive, - onArchive, - onDelete, - onGoToSupervisor, -}: ContractListProps) { - const [filter, setFilter] = useState<ContractStatus | "all">("all"); - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [contextMenuContract, setContextMenuContract] = useState<ContractSummary | null>(null); - - const handleContextMenu = (e: React.MouseEvent, contract: ContractSummary) => { - e.preventDefault(); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuContract(contract); - }; - - const closeContextMenu = () => { - setContextMenuPosition(null); - setContextMenuContract(null); - }; - - const filteredContracts = - filter === "all" - ? contracts - : contracts.filter((c) => c.status === filter); - - if (loading) { - return ( - <div className="panel h-full flex items-center justify-center"> - <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> - </div> - ); - } - - return ( - <div className="panel h-full flex flex-col min-h-0"> - {/* Header */} - <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> - <div className="flex items-center justify-between mb-3"> - <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> - Contracts - </h2> - <button - onClick={onCreate} - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + New - </button> - </div> - - {/* Filter tabs */} - <div className="flex gap-1"> - {(["all", "active", "completed", "archived"] as const).map((status) => ( - <button - key={status} - onClick={() => setFilter(status)} - className={` - px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors - ${ - filter === status - ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" - : "text-[#555] hover:text-[#75aafc]" - } - `} - > - {status} - </button> - ))} - </div> - </div> - - {/* Contract list */} - <div className="flex-1 min-h-0 overflow-y-auto"> - {filteredContracts.length === 0 ? ( - <div className="p-4 text-center"> - <p className="font-mono text-sm text-[#555]"> - {filter === "all" - ? "No contracts yet" - : `No ${filter} contracts`} - </p> - </div> - ) : ( - <div className="divide-y divide-[rgba(117,170,252,0.15)]"> - {filteredContracts.map((contract) => ( - <button - key={contract.id} - onClick={() => onSelect(contract.id)} - onContextMenu={(e) => handleContextMenu(e, contract)} - className={` - w-full text-left p-4 transition-colors - ${ - selectedId === contract.id - ? "bg-[rgba(117,170,252,0.1)]" - : "hover:bg-[rgba(117,170,252,0.05)]" - } - `} - > - <div className="flex items-start justify-between gap-2 mb-2"> - <div className="flex items-center gap-2 min-w-0"> - <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> - {contract.name} - </h3> - {contract.localOnly && ( - <span className="px-1.5 py-0.5 font-mono text-[9px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10 shrink-0"> - Local - </span> - )} - </div> - <span - className={`text-[10px] font-mono uppercase shrink-0 ${ - statusColors[contract.status] - }`} - > - {contract.status} - </span> - </div> - - {contract.description && ( - <p className="font-mono text-xs text-[#555] mb-2 line-clamp-2"> - {contract.description} - </p> - )} - - <div className="flex items-center justify-between"> - <PhaseProgressBarCompact currentPhase={contract.phase} contractType={contract.contractType} /> - <div className="flex items-center gap-3 text-[10px] font-mono text-[#555]"> - {contract.fileCount > 0 && ( - <span>{contract.fileCount} files</span> - )} - {contract.taskCount > 0 && ( - <span>{contract.taskCount} tasks</span> - )} - {contract.repositoryCount > 0 && ( - <span>{contract.repositoryCount} repos</span> - )} - </div> - </div> - </button> - ))} - </div> - )} - </div> - - {/* Context Menu */} - {contextMenuPosition && contextMenuContract && ( - <ContractContextMenu - x={contextMenuPosition.x} - y={contextMenuPosition.y} - contract={contextMenuContract} - onClose={closeContextMenu} - onMarkComplete={() => onMarkComplete?.(contextMenuContract)} - onMarkActive={() => onMarkActive?.(contextMenuContract)} - onArchive={() => onArchive?.(contextMenuContract)} - onDelete={() => onDelete?.(contextMenuContract)} - onGoToSupervisor={() => onGoToSupervisor?.(contextMenuContract)} - /> - )} - </div> - ); -} - -export function ContractCard({ - contract, - onClick, -}: { - contract: ContractSummary; - onClick: () => void; -}) { - return ( - <button - onClick={onClick} - className="w-full text-left p-4 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] transition-colors" - > - <div className="flex items-start justify-between gap-2 mb-2"> - <h3 className="font-mono text-sm text-[#dbe7ff]">{contract.name}</h3> - <PhaseBadge phase={contract.phase} /> - </div> - - {contract.description && ( - <p className="font-mono text-xs text-[#555] mb-3 line-clamp-2"> - {contract.description} - </p> - )} - - <div className="flex items-center gap-4 text-[10px] font-mono text-[#555]"> - <span>{contract.fileCount} files</span> - <span>{contract.taskCount} tasks</span> - <span>{contract.repositoryCount} repos</span> - </div> - </button> - ); -} diff --git a/makima/frontend/src/components/contracts/PhaseBadge.tsx b/makima/frontend/src/components/contracts/PhaseBadge.tsx deleted file mode 100644 index 0f46b9b..0000000 --- a/makima/frontend/src/components/contracts/PhaseBadge.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { ContractPhase } from "../../lib/api"; - -interface PhaseBadgeProps { - phase: ContractPhase; - size?: "sm" | "md"; -} - -const phaseConfig: Record< - ContractPhase, - { label: string; color: string; bgColor: string } -> = { - research: { - label: "Research", - color: "text-purple-400", - bgColor: "bg-purple-400/10 border-purple-400/30", - }, - specify: { - label: "Specify", - color: "text-blue-400", - bgColor: "bg-blue-400/10 border-blue-400/30", - }, - plan: { - label: "Plan", - color: "text-cyan-400", - bgColor: "bg-cyan-400/10 border-cyan-400/30", - }, - execute: { - label: "Execute", - color: "text-yellow-400", - bgColor: "bg-yellow-400/10 border-yellow-400/30", - }, - review: { - label: "Review", - color: "text-green-400", - bgColor: "bg-green-400/10 border-green-400/30", - }, -}; - -export function PhaseBadge({ phase, size = "sm" }: PhaseBadgeProps) { - const config = phaseConfig[phase]; - const sizeClasses = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs"; - - return ( - <span - className={`${sizeClasses} ${config.color} ${config.bgColor} border font-mono uppercase tracking-wider`} - > - {config.label} - </span> - ); -} - -export function getPhaseLabel(phase: ContractPhase): string { - return phaseConfig[phase].label; -} diff --git a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx b/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx deleted file mode 100644 index b2c2e58..0000000 --- a/makima/frontend/src/components/contracts/PhaseDeliverablesPanel.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { useMemo } from "react"; -import type { ContractWithRelations, ContractPhase, ContractType } from "../../lib/api"; - -// Phase deliverables configuration (mirrors backend phase_guidance.rs) -// IDs must match backend phase_guidance.rs exactly for mark_deliverable_complete -interface PhaseDeliverable { - id: string; // Must match backend deliverable ID - name: string; - priority: "required" | "recommended" | "optional"; - description: string; -} - -interface PhaseConfig { - deliverables: PhaseDeliverable[]; - requiresRepository: boolean; - requiresTasks: boolean; - guidance: string; -} - -// Contract type specific deliverables (must match backend phase_guidance.rs) -type ContractTypeDeliverables = Partial<Record<ContractPhase, PhaseConfig>>; - -const CONTRACT_TYPE_DELIVERABLES: Record<ContractType, ContractTypeDeliverables> = { - simple: { - plan: { - deliverables: [ - { id: "plan-document", name: "Plan", priority: "required", description: "Implementation plan detailing the approach and tasks" }, - ], - requiresRepository: true, - requiresTasks: false, - guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.", - }, - execute: { - deliverables: [ - { id: "pull-request", name: "Pull Request", priority: "required", description: "Pull request with the implemented changes" }, - ], - requiresRepository: true, - requiresTasks: true, - guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks to finish the contract.", - }, - }, - specification: { - research: { - deliverables: [ - { id: "research-notes", name: "Research Notes", priority: "required", description: "Document findings and insights during research" }, - ], - requiresRepository: false, - requiresTasks: false, - guidance: "Focus on understanding the problem space and document your findings in the Research Notes before moving to Specify phase.", - }, - specify: { - deliverables: [ - { id: "requirements-document", name: "Requirements Document", priority: "required", description: "Define functional and non-functional requirements" }, - ], - requiresRepository: false, - requiresTasks: false, - guidance: "Define what needs to be built with clear requirements in the Requirements Document. Ensure specifications are detailed enough for planning.", - }, - plan: { - deliverables: [ - { id: "plan-document", name: "Plan", priority: "required", description: "Implementation plan detailing the approach and tasks" }, - ], - requiresRepository: true, - requiresTasks: false, - guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.", - }, - execute: { - deliverables: [ - { id: "pull-request", name: "Pull Request", priority: "required", description: "Pull request with the implemented changes" }, - ], - requiresRepository: true, - requiresTasks: true, - guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks before moving to Review phase.", - }, - review: { - deliverables: [ - { id: "release-notes", name: "Release Notes", priority: "required", description: "Document changes for release communication" }, - ], - requiresRepository: false, - requiresTasks: false, - guidance: "Review completed work and document the release in the Release Notes. The contract can be completed after review.", - }, - }, - execute: { - execute: { - deliverables: [], // No deliverables for execute-only contract type - requiresRepository: true, - requiresTasks: true, - guidance: "Execute the tasks directly. No deliverable documents are required for this contract type.", - }, - }, -}; - -// Get phase config for a specific contract type and phase -function getPhaseConfig(contractType: ContractType, phase: ContractPhase): PhaseConfig { - const typeConfig = CONTRACT_TYPE_DELIVERABLES[contractType]; - const phaseConfig = typeConfig?.[phase]; - - if (phaseConfig) { - return phaseConfig; - } - - // Fallback for unknown phase/type combinations - return { - deliverables: [], - requiresRepository: false, - requiresTasks: false, - guidance: `Unknown phase "${phase}" for contract type "${contractType}"`, - }; -} - -interface DeliverableStatus { - id: string; - name: string; - priority: "required" | "recommended" | "optional"; - description: string; - completed: boolean; - fileId?: string; - actualName?: string; -} - -interface PhaseDeliverablesProps { - contract: ContractWithRelations; - onCreateFile?: (templateId: string, suggestedName: string) => void; -} - -export function PhaseDeliverablesPanel({ contract, onCreateFile }: PhaseDeliverablesProps) { - // Get phase config based on contract type AND phase - const phaseConfig = useMemo( - () => getPhaseConfig(contract.contractType, contract.phase), - [contract.contractType, contract.phase] - ); - - // Calculate deliverable status - const deliverableStatuses = useMemo((): DeliverableStatus[] => { - return phaseConfig.deliverables.map((deliverable) => { - // Find matching file by name similarity - const matchedFile = contract.files.find((f) => { - const nameLower = f.name.toLowerCase(); - const deliverableLower = deliverable.name.toLowerCase(); - return ( - f.contractPhase === contract.phase && - (nameLower.includes(deliverableLower) || deliverableLower.includes(nameLower) || nameLower.includes(deliverable.id.replace("-", " "))) - ); - }); - - return { - ...deliverable, - completed: !!matchedFile, - fileId: matchedFile?.id, - actualName: matchedFile?.name, - }; - }); - }, [contract.files, contract.phase, phaseConfig.deliverables]); - - // Check repository status - const hasRepository = contract.repositories.length > 0; - - // Check task status - const taskStats = useMemo(() => { - const total = contract.tasks.length; - const done = contract.tasks.filter((t) => t.status === "done" || t.status === "merged").length; - const pending = contract.tasks.filter((t) => t.status === "pending").length; - const running = contract.tasks.filter((t) => ["running", "initializing", "starting"].includes(t.status)).length; - const failed = contract.tasks.filter((t) => t.status === "failed").length; - return { total, done, pending, running, failed }; - }, [contract.tasks]); - - // Calculate completion percentage - const completionPercent = useMemo(() => { - let completed = 0; - let total = 0; - - // Count required and recommended deliverables - deliverableStatuses.forEach((s) => { - if (s.priority !== "optional") { - total++; - if (s.completed) completed++; - } - }); - - // Count repository if required - if (phaseConfig.requiresRepository) { - total++; - if (hasRepository) completed++; - } - - // Count tasks if required - if (phaseConfig.requiresTasks && taskStats.total > 0) { - total++; - if (taskStats.done === taskStats.total) completed++; - } - - return total > 0 ? Math.round((completed / total) * 100) : 100; - }, [deliverableStatuses, hasRepository, phaseConfig, taskStats]); - - const priorityColors = { - required: "text-red-400", - recommended: "text-yellow-400", - optional: "text-[#555]", - }; - - return ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <h3 className="font-mono text-xs text-[#75aafc] uppercase"> - Phase Deliverables - </h3> - <div className="flex items-center gap-2"> - <div className="w-24 h-1.5 bg-[rgba(117,170,252,0.1)] rounded overflow-hidden"> - <div - className={`h-full transition-all duration-300 ${ - completionPercent === 100 ? "bg-green-400" : "bg-[#75aafc]" - }`} - style={{ width: `${completionPercent}%` }} - /> - </div> - <span className="font-mono text-[10px] text-[#555]">{completionPercent}%</span> - </div> - </div> - - {/* Guidance text */} - <p className="font-mono text-xs text-[#555] italic">{phaseConfig.guidance}</p> - - {/* Deliverables checklist */} - <div className="space-y-2"> - {deliverableStatuses.map((status) => ( - <div - key={status.id} - className={`flex items-center justify-between p-2 border ${ - status.completed - ? "border-green-400/20 bg-green-400/5" - : "border-[rgba(117,170,252,0.15)]" - }`} - > - <div className="flex items-center gap-2"> - <span - className={`font-mono text-xs ${ - status.completed ? "text-green-400" : "text-[#555]" - }`} - > - {status.completed ? "[+]" : "[ ]"} - </span> - <div> - <div className="flex items-center gap-2"> - <span className="font-mono text-xs text-[#dbe7ff]"> - {status.completed ? status.actualName : status.name} - </span> - {!status.completed && ( - <span className={`font-mono text-[9px] uppercase ${priorityColors[status.priority]}`}> - {status.priority} - </span> - )} - </div> - <span className="font-mono text-[10px] text-[#555]"> - {status.description} - </span> - </div> - </div> - {!status.completed && onCreateFile && ( - <button - onClick={() => onCreateFile(status.id, status.name)} - className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - Create - </button> - )} - </div> - ))} - </div> - - {/* Repository status */} - {phaseConfig.requiresRepository && ( - <div - className={`flex items-center gap-2 p-2 border ${ - hasRepository - ? "border-green-400/20 bg-green-400/5" - : "border-[rgba(117,170,252,0.15)]" - }`} - > - <span - className={`font-mono text-xs ${ - hasRepository ? "text-green-400" : "text-[#555]" - }`} - > - {hasRepository ? "[+]" : "[ ]"} - </span> - <div> - <span className="font-mono text-xs text-[#dbe7ff]"> - Repository Configured - </span> - {!hasRepository && ( - <span className="font-mono text-[9px] uppercase text-red-400 ml-2"> - required - </span> - )} - </div> - </div> - )} - - {/* Task status */} - {phaseConfig.requiresTasks && ( - <div - className={`flex items-center justify-between p-2 border ${ - taskStats.total > 0 && taskStats.done === taskStats.total - ? "border-green-400/20 bg-green-400/5" - : "border-[rgba(117,170,252,0.15)]" - }`} - > - <div className="flex items-center gap-2"> - <span - className={`font-mono text-xs ${ - taskStats.total > 0 && taskStats.done === taskStats.total - ? "text-green-400" - : "text-[#555]" - }`} - > - {taskStats.total > 0 && taskStats.done === taskStats.total ? "[+]" : "[ ]"} - </span> - <span className="font-mono text-xs text-[#dbe7ff]"> - Tasks Completed - </span> - </div> - {taskStats.total > 0 ? ( - <span className="font-mono text-[10px] text-[#9bc3ff]"> - {taskStats.done}/{taskStats.total} - {taskStats.running > 0 && ` (${taskStats.running} running)`} - {taskStats.failed > 0 && ( - <span className="text-red-400"> ({taskStats.failed} failed)</span> - )} - </span> - ) : ( - <span className="font-mono text-[10px] text-[#555]">No tasks yet</span> - )} - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/PhaseHint.tsx b/makima/frontend/src/components/contracts/PhaseHint.tsx deleted file mode 100644 index 95573ed..0000000 --- a/makima/frontend/src/components/contracts/PhaseHint.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { ContractPhase, ContractWithRelations } from "../../lib/api"; - -interface PhaseHintProps { - contract: ContractWithRelations; - onAdvancePhase: (phase: ContractPhase) => void; -} - -const phaseOrder: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; - -interface HintConfig { - condition: (contract: ContractWithRelations) => boolean; - message: (contract: ContractWithRelations) => string; - nextPhase: ContractPhase; -} - -const phaseHints: Record<ContractPhase, HintConfig | null> = { - research: { - condition: (c) => c.files.length >= 1, - message: (c) => - `You have ${c.files.length} file${c.files.length === 1 ? "" : "s"}. Ready to specify requirements?`, - nextPhase: "specify", - }, - specify: { - condition: (c) => c.files.length >= 2, - message: () => "Spec files ready. Create implementation plan?", - nextPhase: "plan", - }, - plan: { - condition: (c) => c.files.length >= 1 && c.repositories.length >= 1, - message: () => "Plan documented. Ready to create tasks?", - nextPhase: "execute", - }, - execute: { - condition: (c) => { - // Show hint only when all tasks are complete - const doneTasks = c.tasks.filter( - (t) => t.status === "done" || t.status === "merged" - ); - return c.tasks.length > 0 && doneTasks.length === c.tasks.length; - }, - message: (c) => { - const doneTasks = c.tasks.filter( - (t) => t.status === "done" || t.status === "merged" - ); - return `All ${doneTasks.length} task${doneTasks.length === 1 ? "" : "s"} complete. Ready for review?`; - }, - nextPhase: "review", - }, - review: null, // No hint for review phase - it's the final phase -}; - -export function PhaseHint({ contract, onAdvancePhase }: PhaseHintProps) { - const hintConfig = phaseHints[contract.phase]; - - // No hint for this phase - if (!hintConfig) return null; - - // Condition not met - if (!hintConfig.condition(contract)) return null; - - return ( - <div className="flex items-center gap-3 p-3 bg-[rgba(117,170,252,0.05)] border border-[rgba(117,170,252,0.2)]"> - <span className="font-mono text-xs text-[#9bc3ff]"> - {hintConfig.message(contract)} - </span> - <button - onClick={() => onAdvancePhase(hintConfig.nextPhase)} - className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase whitespace-nowrap" - > - Advance to {hintConfig.nextPhase} - </button> - </div> - ); -} - -export function getNextPhase(currentPhase: ContractPhase): ContractPhase | null { - const currentIndex = phaseOrder.indexOf(currentPhase); - if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) { - return null; - } - return phaseOrder[currentIndex + 1]; -} - -export function getPreviousPhase(currentPhase: ContractPhase): ContractPhase | null { - const currentIndex = phaseOrder.indexOf(currentPhase); - if (currentIndex <= 0) { - return null; - } - return phaseOrder[currentIndex - 1]; -} diff --git a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx b/makima/frontend/src/components/contracts/PhaseProgressBar.tsx deleted file mode 100644 index 9589db9..0000000 --- a/makima/frontend/src/components/contracts/PhaseProgressBar.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import type { ContractPhase, ContractType } from "../../lib/api"; -import { getValidPhases } from "../../lib/api"; - -interface PhaseProgressBarProps { - currentPhase: ContractPhase; - contractType?: ContractType; - onPhaseClick?: (phase: ContractPhase) => void; - readonly?: boolean; -} - -const phases: ContractPhase[] = ["research", "specify", "plan", "execute", "review"]; - -const phaseLabels: Record<ContractPhase, string> = { - research: "Research", - specify: "Specify", - plan: "Plan", - execute: "Execute", - review: "Review", -}; - -const phaseColors: Record<ContractPhase, { active: string; inactive: string; completed: string }> = { - research: { - active: "bg-purple-400 border-purple-400", - inactive: "bg-transparent border-purple-400/30", - completed: "bg-purple-400/50 border-purple-400/50", - }, - specify: { - active: "bg-blue-400 border-blue-400", - inactive: "bg-transparent border-blue-400/30", - completed: "bg-blue-400/50 border-blue-400/50", - }, - plan: { - active: "bg-cyan-400 border-cyan-400", - inactive: "bg-transparent border-cyan-400/30", - completed: "bg-cyan-400/50 border-cyan-400/50", - }, - execute: { - active: "bg-yellow-400 border-yellow-400", - inactive: "bg-transparent border-yellow-400/30", - completed: "bg-yellow-400/50 border-yellow-400/50", - }, - review: { - active: "bg-green-400 border-green-400", - inactive: "bg-transparent border-green-400/30", - completed: "bg-green-400/50 border-green-400/50", - }, -}; - -export function PhaseProgressBar({ - currentPhase, - contractType, - onPhaseClick, - readonly = false, -}: PhaseProgressBarProps) { - const visiblePhases = contractType ? getValidPhases(contractType) : phases; - const currentIndex = visiblePhases.indexOf(currentPhase); - - return ( - <div className="flex items-center gap-1"> - {visiblePhases.map((phase, index) => { - const isActive = phase === currentPhase; - const isCompleted = index < currentIndex; - const colors = phaseColors[phase]; - const colorClass = isActive - ? colors.active - : isCompleted - ? colors.completed - : colors.inactive; - - const canClick = !readonly && onPhaseClick; - - return ( - <div key={phase} className="flex items-center"> - {/* Phase node */} - <button - onClick={() => canClick && onPhaseClick(phase)} - disabled={readonly} - className={` - relative group flex flex-col items-center - ${canClick ? "cursor-pointer" : "cursor-default"} - `} - > - {/* Circle */} - <div - className={` - w-3 h-3 rounded-full border-2 transition-all - ${colorClass} - ${canClick && !isActive ? "hover:scale-110" : ""} - `} - /> - {/* Label */} - <span - className={` - absolute top-4 font-mono text-[9px] uppercase tracking-wide whitespace-nowrap - ${isActive ? "text-[#dbe7ff]" : "text-[#555]"} - ${canClick && !isActive ? "group-hover:text-[#75aafc]" : ""} - `} - > - {phaseLabels[phase]} - </span> - </button> - - {/* Connector line */} - {index < visiblePhases.length - 1 && ( - <div - className={` - w-8 h-0.5 mx-1 - ${index < currentIndex ? "bg-[#3f6fb3]" : "bg-[rgba(117,170,252,0.15)]"} - `} - /> - )} - </div> - ); - })} - </div> - ); -} - -export function PhaseProgressBarCompact({ - currentPhase, - contractType, -}: { - currentPhase: ContractPhase; - contractType?: ContractType; -}) { - const visiblePhases = contractType ? getValidPhases(contractType) : phases; - const currentIndex = visiblePhases.indexOf(currentPhase); - - return ( - <div className="flex items-center gap-0.5"> - {visiblePhases.map((phase, index) => { - const isActive = phase === currentPhase; - const isCompleted = index < currentIndex; - const colors = phaseColors[phase]; - - return ( - <div - key={phase} - className={` - w-2 h-2 rounded-full border - ${isActive ? colors.active : isCompleted ? colors.completed : colors.inactive} - `} - title={phaseLabels[phase]} - /> - ); - })} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/QuickActionButtons.tsx b/makima/frontend/src/components/contracts/QuickActionButtons.tsx deleted file mode 100644 index 4dbb90c..0000000 --- a/makima/frontend/src/components/contracts/QuickActionButtons.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { useCallback } from "react"; - -export type QuickActionType = - | "create_file" - | "create_task" - | "run_task" - | "advance_phase" - | "derive_tasks" - | "update_file"; - -export interface QuickAction { - type: QuickActionType; - label: string; - description?: string; - data?: Record<string, unknown>; -} - -interface QuickActionButtonsProps { - actions: QuickAction[]; - onAction: (action: QuickAction) => void; - loading?: boolean; -} - -const ACTION_ICONS: Record<QuickActionType, string> = { - create_file: "[+]", - create_task: "[T]", - run_task: "[>]", - advance_phase: "[→]", - derive_tasks: "[≡]", - update_file: "[*]", -}; - -const ACTION_COLORS: Record<QuickActionType, string> = { - create_file: "border-blue-400/30 hover:border-blue-400/60 text-blue-400", - create_task: "border-green-400/30 hover:border-green-400/60 text-green-400", - run_task: "border-yellow-400/30 hover:border-yellow-400/60 text-yellow-400", - advance_phase: "border-purple-400/30 hover:border-purple-400/60 text-purple-400", - derive_tasks: "border-cyan-400/30 hover:border-cyan-400/60 text-cyan-400", - update_file: "border-orange-400/30 hover:border-orange-400/60 text-orange-400", -}; - -export function QuickActionButtons({ - actions, - onAction, - loading = false, -}: QuickActionButtonsProps) { - const handleClick = useCallback( - (action: QuickAction) => { - if (!loading) { - onAction(action); - } - }, - [onAction, loading] - ); - - if (actions.length === 0) return null; - - return ( - <div className="flex flex-wrap gap-2 mt-2"> - {actions.map((action, index) => ( - <button - key={`${action.type}-${index}`} - onClick={() => handleClick(action)} - disabled={loading} - className={` - flex items-center gap-1.5 px-2 py-1 - font-mono text-[10px] uppercase - border transition-colors - disabled:opacity-50 disabled:cursor-not-allowed - ${ACTION_COLORS[action.type]} - `} - title={action.description} - > - <span>{ACTION_ICONS[action.type]}</span> - <span>{action.label}</span> - </button> - ))} - </div> - ); -} - -/** - * Parse tool call results to extract suggested quick actions. - * This is used by ContractCliInput to detect actionable results. - */ -export function parseActionsFromToolCalls( - toolCalls: { name: string; success: boolean; message: string }[] -): QuickAction[] { - const actions: QuickAction[] = []; - - for (const tc of toolCalls) { - if (!tc.success) continue; - - switch (tc.name) { - case "derive_tasks_from_file": - // When tasks are parsed, offer to create them - if (tc.message.includes("task") || tc.message.includes("Found")) { - actions.push({ - type: "derive_tasks", - label: "Review & Create Tasks", - description: "Review parsed tasks and create them with chaining", - }); - } - break; - - case "process_task_completion": - // Check for suggested actions in the result - if (tc.message.includes("next task")) { - actions.push({ - type: "run_task", - label: "Run Next Task", - description: "Continue with the next chained task", - }); - } - if (tc.message.includes("advance") || tc.message.includes("phase")) { - actions.push({ - type: "advance_phase", - label: "Advance Phase", - description: "Move to the next contract phase", - }); - } - break; - - case "get_phase_checklist": - // When checklist shows missing items, offer to create them - if (tc.message.includes("missing") || tc.message.includes("not created")) { - actions.push({ - type: "create_file", - label: "Create Missing Files", - description: "Create files from recommended templates", - }); - } - break; - - case "advance_phase": - // After phase transition, suggest creating files - actions.push({ - type: "create_file", - label: "Create Phase Files", - description: "Create recommended files for this phase", - }); - break; - } - } - - return actions; -} - -/** - * Parse LLM response text to detect suggested actions. - * Used as a fallback when structured action data isn't available. - */ -export function parseActionsFromText(text: string): QuickAction[] { - const actions: QuickAction[] = []; - const lower = text.toLowerCase(); - - // Detect file creation suggestions - if ( - lower.includes("create a file") || - lower.includes("create the file") || - lower.includes("should i create") - ) { - actions.push({ - type: "create_file", - label: "Create File", - description: "Create the suggested file", - }); - } - - // Detect task creation suggestions - if ( - lower.includes("create tasks") || - lower.includes("create these tasks") || - lower.includes("create chained tasks") - ) { - actions.push({ - type: "create_task", - label: "Create Tasks", - description: "Create the suggested tasks", - }); - } - - // Detect phase advancement suggestions - if ( - lower.includes("advance to") || - lower.includes("ready to move to") || - lower.includes("transition to") - ) { - const phases = ["specify", "plan", "execute", "review"]; - for (const phase of phases) { - if (lower.includes(phase)) { - actions.push({ - type: "advance_phase", - label: `Advance to ${phase.charAt(0).toUpperCase() + phase.slice(1)}`, - description: `Move to the ${phase} phase`, - data: { phase }, - }); - break; - } - } - } - - // Detect run task suggestions - if ( - lower.includes("run the task") || - lower.includes("start the task") || - lower.includes("run task") - ) { - actions.push({ - type: "run_task", - label: "Run Task", - description: "Start the suggested task", - }); - } - - return actions; -} diff --git a/makima/frontend/src/components/contracts/RepositoryPanel.tsx b/makima/frontend/src/components/contracts/RepositoryPanel.tsx deleted file mode 100644 index 15741a8..0000000 --- a/makima/frontend/src/components/contracts/RepositoryPanel.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { useState, useEffect } from "react"; -import type { - ContractRepository, - RepositorySourceType, - RepositoryStatus, - DaemonDirectory, - RepositoryHistoryEntry, -} from "../../lib/api"; -import { getDaemonDirectories, getRepositorySuggestions } from "../../lib/api"; -import { DirectoryInput } from "../mesh/DirectoryInput"; - -interface RepositoryPanelProps { - repositories: ContractRepository[]; - onAddRemote: (name: string, url: string, isPrimary: boolean) => void; - onAddLocal: (name: string, path: string, isPrimary: boolean) => void; - onCreateManaged: (name: string, isPrimary: boolean) => void; - onDelete: (repoId: string) => void; - onSetPrimary: (repoId: string) => void; -} - -type AddMode = "remote" | "local" | "managed" | null; - -const sourceTypeLabels: Record<RepositorySourceType, string> = { - remote: "Remote", - local: "Local", - managed: "Managed", -}; - -const sourceTypeIcons: Record<RepositorySourceType, string> = { - remote: "GH", - local: "FS", - managed: "MK", -}; - -const statusColors: Record<RepositoryStatus, string> = { - ready: "text-green-400", - pending: "text-yellow-400", - creating: "text-cyan-400", - failed: "text-red-400", -}; - -export function RepositoryPanel({ - repositories, - onAddRemote, - onAddLocal, - onCreateManaged, - onDelete, - onSetPrimary, -}: RepositoryPanelProps) { - const [addMode, setAddMode] = useState<AddMode>(null); - const [name, setName] = useState(""); - const [url, setUrl] = useState(""); - const [path, setPath] = useState(""); - const [isPrimary, setIsPrimary] = useState(false); - // Daemon directory suggestions for local repositories - const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); - // Repository history suggestions - const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showSuggestions, setShowSuggestions] = useState(false); - - // Fetch daemon directories when "local" mode is selected - useEffect(() => { - if (addMode === "local") { - getDaemonDirectories() - .then((res) => setSuggestedDirectories(res.directories)) - .catch(() => setSuggestedDirectories([])); - } - }, [addMode]); - - // Fetch repository suggestions when mode changes to remote or local - useEffect(() => { - if (addMode === "remote" || addMode === "local") { - getRepositorySuggestions(addMode, undefined, 10) - .then((res) => { - setRepoSuggestions(res.entries); - setShowSuggestions(res.entries.length > 0); - }) - .catch(() => { - setRepoSuggestions([]); - setShowSuggestions(false); - }); - } else { - setRepoSuggestions([]); - setShowSuggestions(false); - } - }, [addMode]); - - // Apply a suggestion to the form - const applySuggestion = (suggestion: RepositoryHistoryEntry) => { - setName(suggestion.name); - if (suggestion.repositoryUrl) { - setUrl(suggestion.repositoryUrl); - } - if (suggestion.localPath) { - setPath(suggestion.localPath); - } - setShowSuggestions(false); - }; - - const handleAdd = () => { - if (!name.trim()) return; - - if (addMode === "remote" && url.trim()) { - onAddRemote(name.trim(), url.trim(), isPrimary); - } else if (addMode === "local" && path.trim()) { - onAddLocal(name.trim(), path.trim(), isPrimary); - } else if (addMode === "managed") { - onCreateManaged(name.trim(), isPrimary); - } - - // Reset form - setAddMode(null); - setName(""); - setUrl(""); - setPath(""); - setIsPrimary(false); - }; - - const handleCancel = () => { - setAddMode(null); - setName(""); - setUrl(""); - setPath(""); - setIsPrimary(false); - }; - - return ( - <div className="space-y-4"> - {/* Repository list */} - {repositories.length === 0 ? ( - <p className="font-mono text-xs text-[#555]"> - No repositories configured - </p> - ) : ( - <div className="space-y-2"> - {repositories.map((repo) => ( - <div - key={repo.id} - className="flex items-center gap-3 p-3 border border-[rgba(117,170,252,0.2)]" - > - {/* Type icon */} - <span className="font-mono text-[10px] text-[#555] uppercase w-6"> - {sourceTypeIcons[repo.sourceType]} - </span> - - {/* Name and details */} - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="font-mono text-sm text-[#dbe7ff] truncate"> - {repo.name} - </span> - {repo.isPrimary && ( - <span className="px-1 py-0.5 text-[8px] font-mono uppercase bg-[rgba(117,170,252,0.1)] text-[#75aafc] border border-[rgba(117,170,252,0.3)]"> - Primary - </span> - )} - </div> - <div className="font-mono text-[10px] text-[#555] truncate"> - {repo.repositoryUrl || repo.localPath || "(pending creation)"} - </div> - </div> - - {/* Status */} - <span - className={`font-mono text-[10px] uppercase ${ - statusColors[repo.status] - }`} - > - {repo.status} - </span> - - {/* Actions */} - <div className="flex items-center gap-1"> - {!repo.isPrimary && repo.status === "ready" && ( - <button - onClick={() => onSetPrimary(repo.id)} - className="p-1 font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors" - title="Set as primary" - > - * - </button> - )} - <button - onClick={() => onDelete(repo.id)} - className="p-1 font-mono text-[10px] text-[#555] hover:text-red-400 transition-colors" - title="Remove" - > - x - </button> - </div> - </div> - ))} - </div> - )} - - {/* Add repository form */} - {addMode ? ( - <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3"> - <div className="flex items-center justify-between mb-2"> - <span className="font-mono text-xs text-[#75aafc] uppercase"> - Add {sourceTypeLabels[addMode]} Repository - </span> - {repoSuggestions.length > 0 && ( - <button - onClick={() => setShowSuggestions(!showSuggestions)} - className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] transition-colors" - > - {showSuggestions ? "Hide suggestions" : `${repoSuggestions.length} suggestions`} - </button> - )} - </div> - - {/* Suggestions dropdown */} - {showSuggestions && repoSuggestions.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> - {repoSuggestions.map((suggestion) => ( - <button - key={suggestion.id} - onClick={() => applySuggestion(suggestion)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] transition-colors border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> - <span className="text-[10px] text-[#556677]"> - {suggestion.useCount}× - </span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {addMode === "local" ? suggestion.localPath : suggestion.repositoryUrl} - </div> - </button> - ))} - </div> - )} - - <input - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - placeholder="Repository name" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" - /> - - {addMode === "remote" && ( - <input - type="text" - value={url} - onChange={(e) => setUrl(e.target.value)} - placeholder="https://github.com/owner/repo" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" - /> - )} - - {addMode === "local" && ( - <DirectoryInput - value={path} - onChange={setPath} - suggestions={suggestedDirectories} - placeholder="/path/to/repository" - /> - )} - - {addMode === "managed" && ( - <p className="font-mono text-xs text-[#555]"> - Makima will create this repository via the daemon. - </p> - )} - - <label className="flex items-center gap-2 cursor-pointer"> - <input - type="checkbox" - checked={isPrimary} - onChange={(e) => setIsPrimary(e.target.checked)} - className="w-3 h-3" - /> - <span className="font-mono text-xs text-[#9bc3ff]"> - Set as primary repository - </span> - </label> - - <div className="flex gap-2"> - <button - onClick={handleCancel} - className="px-3 py-1.5 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleAdd} - disabled={ - !name.trim() || - (addMode === "remote" && !url.trim()) || - (addMode === "local" && !path.trim()) - } - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Add Repository - </button> - </div> - </div> - ) : ( - <div className="flex gap-2"> - <button - onClick={() => setAddMode("remote")} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + Remote - </button> - <button - onClick={() => setAddMode("local")} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + Local - </button> - <button - onClick={() => setAddMode("managed")} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors" - > - + Managed - </button> - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx b/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx deleted file mode 100644 index 07421ef..0000000 --- a/makima/frontend/src/components/contracts/TaskDerivationPreview.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useState, useCallback } from "react"; - -export interface ParsedTask { - name: string; - description?: string; - group?: string; - order: number; - completed: boolean; - dependencies: string[]; -} - -interface TaskDerivationPreviewProps { - tasks: ParsedTask[]; - groups: string[]; - fileName: string; - onCreateTasks: (selectedTasks: ParsedTask[]) => void; - onCancel: () => void; - loading?: boolean; -} - -export function TaskDerivationPreview({ - tasks, - groups, - fileName, - onCreateTasks, - onCancel, - loading = false, -}: TaskDerivationPreviewProps) { - const [selectedIndices, setSelectedIndices] = useState<Set<number>>( - new Set(tasks.map((_, i) => i)) // Select all by default - ); - - const toggleTask = useCallback((index: number) => { - setSelectedIndices((prev) => { - const newSet = new Set(prev); - if (newSet.has(index)) { - newSet.delete(index); - } else { - newSet.add(index); - } - return newSet; - }); - }, []); - - const selectAll = useCallback(() => { - setSelectedIndices(new Set(tasks.map((_, i) => i))); - }, [tasks]); - - const selectNone = useCallback(() => { - setSelectedIndices(new Set()); - }, []); - - const handleCreate = useCallback(() => { - const selectedTasks = tasks.filter((_, i) => selectedIndices.has(i)); - onCreateTasks(selectedTasks); - }, [tasks, selectedIndices, onCreateTasks]); - - // Group tasks by their group property - const tasksByGroup = tasks.reduce((acc, task, index) => { - const groupKey = task.group || "Ungrouped"; - if (!acc[groupKey]) { - acc[groupKey] = []; - } - acc[groupKey].push({ task, index }); - return acc; - }, {} as Record<string, { task: ParsedTask; index: number }[]>); - - const selectedCount = selectedIndices.size; - const totalCount = tasks.length; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> - <div className="w-full max-w-2xl p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[80vh] flex flex-col"> - {/* Header */} - <div className="flex items-center justify-between mb-4"> - <div> - <h3 className="font-mono text-sm text-[#75aafc] uppercase"> - Create Tasks from Document - </h3> - <p className="font-mono text-xs text-[#555] mt-1"> - Source: {fileName} - </p> - </div> - <div className="flex items-center gap-2"> - <button - onClick={selectAll} - className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" - > - Select All - </button> - <span className="text-[#555]">|</span> - <button - onClick={selectNone} - className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" - > - Select None - </button> - </div> - </div> - - {/* Task List */} - <div className="flex-1 overflow-y-auto space-y-4 mb-4"> - {groups.length > 0 ? ( - // Grouped view - Object.entries(tasksByGroup).map(([groupName, groupTasks]) => ( - <div key={groupName} className="space-y-2"> - <h4 className="font-mono text-xs text-[#9bc3ff] uppercase border-b border-[rgba(117,170,252,0.2)] pb-1"> - {groupName} - </h4> - {groupTasks.map(({ task, index }) => ( - <TaskItem - key={index} - task={task} - index={index} - selected={selectedIndices.has(index)} - onToggle={() => toggleTask(index)} - /> - ))} - </div> - )) - ) : ( - // Flat view - tasks.map((task, index) => ( - <TaskItem - key={index} - task={task} - index={index} - selected={selectedIndices.has(index)} - onToggle={() => toggleTask(index)} - /> - )) - )} - </div> - - {/* Footer */} - <div className="flex items-center justify-between pt-4 border-t border-[rgba(117,170,252,0.2)]"> - <span className="font-mono text-xs text-[#555]"> - {selectedCount} of {totalCount} tasks selected - </span> - <div className="flex gap-2"> - <button - onClick={onCancel} - disabled={loading} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors disabled:opacity-50" - > - Cancel - </button> - <button - onClick={handleCreate} - disabled={loading || selectedCount === 0} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - {loading ? "Creating..." : `Create ${selectedCount} Task${selectedCount !== 1 ? "s" : ""}`} - </button> - </div> - </div> - - {/* Chaining info */} - {selectedCount > 1 && ( - <p className="font-mono text-[10px] text-[#555] mt-2 text-center"> - Tasks will be chained: each task continues from the previous one's work - </p> - )} - </div> - </div> - ); -} - -function TaskItem({ - task, - index, - selected, - onToggle, -}: { - task: ParsedTask; - index: number; - selected: boolean; - onToggle: () => void; -}) { - return ( - <button - onClick={onToggle} - className={`w-full text-left p-3 border transition-colors ${ - selected - ? "border-[#75aafc] bg-[rgba(117,170,252,0.1)]" - : "border-[rgba(117,170,252,0.15)] hover:border-[rgba(117,170,252,0.3)]" - }`} - > - <div className="flex items-start gap-2"> - <span - className={`font-mono text-xs mt-0.5 ${ - selected ? "text-[#75aafc]" : "text-[#555]" - }`} - > - {selected ? "[x]" : "[ ]"} - </span> - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2"> - <span className="font-mono text-[10px] text-[#555]">#{index + 1}</span> - <span className="font-mono text-sm text-[#dbe7ff]">{task.name}</span> - {task.completed && ( - <span className="font-mono text-[9px] text-green-400 uppercase"> - done in source - </span> - )} - </div> - {task.description && ( - <p className="font-mono text-xs text-[#555] mt-1 truncate"> - {task.description} - </p> - )} - {task.dependencies.length > 0 && ( - <p className="font-mono text-[10px] text-[#75aafc] mt-1"> - Depends on: {task.dependencies.join(", ")} - </p> - )} - </div> - </div> - </button> - ); -} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index bbb72f3..c1c6c35 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,14 +12,12 @@ import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; -import ContractsPage from "./routes/contracts"; import OrdersPage from "./routes/orders"; import MeshPage from "./routes/mesh"; import DaemonsPage from "./routes/daemons"; import HistoryPage from "./routes/history"; import LoginPage from "./routes/login"; import SettingsPage from "./routes/settings"; -import ContractFilePage from "./routes/contract-file"; import SpeakPage from "./routes/speak"; import DirectivesPage from "./routes/directives"; import ExecRedirect from "./routes/exec-redirect"; @@ -62,30 +60,6 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/contracts" - element={ - <ProtectedRoute> - <ContractsPage /> - </ProtectedRoute> - } - /> - <Route - path="/contracts/:id" - element={ - <ProtectedRoute> - <ContractsPage /> - </ProtectedRoute> - } - /> - <Route - path="/contracts/:id/files/:fileId" - element={ - <ProtectedRoute> - <ContractFilePage /> - </ProtectedRoute> - } - /> - <Route path="/orders" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/contract-file.tsx b/makima/frontend/src/routes/contract-file.tsx deleted file mode 100644 index 9ed25ed..0000000 --- a/makima/frontend/src/routes/contract-file.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import { useEffect, useState, useCallback, useRef } from "react"; -import { useParams, useNavigate } from "react-router"; -import { useAuth } from "../contexts/AuthContext"; -import { Masthead } from "../components/Masthead"; -import { FileDetail, type FocusedElement } from "../components/files/FileDetail"; -import { CliInput } from "../components/files/CliInput"; -import { ConflictNotification } from "../components/files/ConflictNotification"; -import { UpdateNotification } from "../components/files/UpdateNotification"; -import { useFiles } from "../hooks/useFiles"; -import { useVersionHistory } from "../hooks/useVersionHistory"; -import { - useFileSubscription, - type FileUpdateEvent, -} from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; - -/** - * ContractFilePage - Wrapper for viewing files within a contract context - * - * This component handles the /contracts/:contractId/files/:fileId route, - * providing navigation back to the contract and rendering the file detail view. - */ -export default function ContractFilePage() { - const { id: contractId, fileId } = useParams<{ id: string; fileId: string }>(); - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - // Render the file page with contract context - return <ContractAwareFilesPage contractId={contractId} fileId={fileId} />; -} - -// A version of the files page aware of contract context -function ContractAwareFilesPage({ - contractId, - fileId, -}: { - contractId?: string; - fileId?: string; -}) { - const navigate = useNavigate(); - const { error, conflict, clearConflict, fetchFile, editFile, removeFile } = useFiles(); - const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); - const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); - const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); - const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); - const pendingUpdateRef = useRef(false); - const lastSentVersionRef = useRef<number | null>(null); - const lastSavedVersionRef = useRef<number | null>(null); - const hasLocalChangesRef = useRef(false); - const isActivelyEditingRef = useRef(false); - const currentVersionRef = useRef<number | null>(null); - - // Handle back navigation - go to contract detail instead of /files - const handleBack = useCallback(() => { - if (contractId) { - navigate(`/contracts/${contractId}`); - } else { - navigate("/contracts"); - } - }, [contractId, navigate]); - - const updateHasLocalChanges = useCallback((value: boolean) => { - hasLocalChangesRef.current = value; - }, []); - - const updateIsActivelyEditing = useCallback((value: boolean) => { - isActivelyEditingRef.current = value; - }, []); - - // Version history - const { - versions, - loading: versionsLoading, - selectedVersion, - loadingVersion, - restoring, - fetchVersion, - restoreToVersion, - clearSelectedVersion, - fetchVersions, - } = useVersionHistory({ - fileId: fileId || null, - currentVersion: fileDetail?.version || 0, - }); - - const handleRestoreVersion = useCallback( - async (targetVersion: number) => { - const result = await restoreToVersion(targetVersion); - if (result) { - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - fetchVersions(); - } - }, - [restoreToVersion, fetchVersions, updateHasLocalChanges] - ); - - // Load file detail when fileId is provided - useEffect(() => { - if (fileId) { - setDetailLoading(true); - updateHasLocalChanges(false); - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - lastSavedVersionRef.current = null; - currentVersionRef.current = null; - setRemoteUpdate(null); - setRemoteFileData(null); - setFocusedElement(null); - fetchFile(fileId).then((detail) => { - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - setDetailLoading(false); - }); - } else { - setFileDetail(null); - currentVersionRef.current = null; - updateHasLocalChanges(false); - } - }, [fileId, fetchFile, updateHasLocalChanges]); - - // Handle file update events from WebSocket - const handleFileUpdate = useCallback( - async (event: FileUpdateEvent) => { - if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) { - lastSavedVersionRef.current = null; - return; - } - - if (pendingUpdateRef.current) { - if (lastSentVersionRef.current !== null) { - const expectedNewVersion = lastSentVersionRef.current + 1; - if (event.version === expectedNewVersion) { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - return; - } - } - return; - } - - if (currentVersionRef.current !== null && event.version === currentVersionRef.current) { - return; - } - - if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) { - const detail = await fetchFile(event.fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - } else { - const remoteData = await fetchFile(event.fileId); - setRemoteFileData(remoteData); - setRemoteUpdate(event); - } - }, - [fetchFile] - ); - - useFileSubscription({ - fileId: fileId || null, - onUpdate: handleFileUpdate, - }); - - const handleDelete = useCallback( - async (id: string) => { - if (confirm("Are you sure you want to delete this file?")) { - const success = await removeFile(id); - if (success && fileId === id) { - handleBack(); - } - } - }, - [removeFile, fileId, handleBack] - ); - - const handleSave = useCallback( - async (id: string, name: string, description: string) => { - if (!fileDetail) return; - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(id, { name, description, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - }, - [editFile, fileDetail, updateHasLocalChanges] - ); - - const handleBodyUpdate = useCallback( - (body: BodyElement[], summary: string | null) => { - if (fileDetail) { - setFileDetail({ - ...fileDetail, - body, - summary, - }); - } - }, - [fileDetail] - ); - - const handleBodyElementUpdate = useCallback( - async (index: number, element: BodyElement) => { - if (fileDetail && fileId) { - const newBody = [...fileDetail.body]; - newBody[index] = element; - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges] - ); - - const handleBodyReorder = useCallback( - async (fromIndex: number, toIndex: number) => { - if (fileDetail && fileId) { - const newBody = [...fileDetail.body]; - const [movedElement] = newBody.splice(fromIndex, 1); - newBody.splice(toIndex, 0, movedElement); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges] - ); - - const handleBodyElementDelete = useCallback( - async (index: number) => { - if (fileDetail && fileId) { - const newBody = fileDetail.body.filter((_, i) => i !== index); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - if (focusedElement?.index === index) { - setFocusedElement(null); - } else if (focusedElement && focusedElement.index > index) { - setFocusedElement({ - ...focusedElement, - index: focusedElement.index - 1, - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleBodyElementDuplicate = useCallback( - async (index: number) => { - if (fileDetail && fileId) { - const elementToDuplicate = fileDetail.body[index]; - if (!elementToDuplicate) return; - - const newBody = [...fileDetail.body]; - newBody.splice(index + 1, 0, { ...elementToDuplicate }); - - setFileDetail({ - ...fileDetail, - body: newBody, - }); - updateHasLocalChanges(true); - - if (focusedElement && focusedElement.index > index) { - setFocusedElement({ - ...focusedElement, - index: focusedElement.index + 1, - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleFocusElement = useCallback((element: FocusedElement | null) => { - setFocusedElement(element); - }, []); - - const handleClearFocus = useCallback(() => { - setFocusedElement(null); - }, []); - - const handleConvertElement = useCallback( - async (index: number, toType: string) => { - if (!fileDetail || !fileId) return; - - const element = fileDetail.body[index]; - if (!element) return; - - let textContent = ""; - switch (element.type) { - case "heading": - case "paragraph": - textContent = element.text; - break; - case "code": - textContent = element.content; - break; - case "list": - textContent = element.items.join("\n"); - break; - default: - return; - } - - let newElement: BodyElement; - if (toType === "paragraph") { - newElement = { type: "paragraph", text: textContent }; - } else if (toType === "list_unordered") { - const items = textContent.split("\n").filter(line => line.trim()); - newElement = { type: "list", ordered: false, items }; - } else if (toType === "list_ordered") { - const items = textContent.split("\n").filter(line => line.trim()); - newElement = { type: "list", ordered: true, items }; - } else if (toType === "code") { - newElement = { type: "code", content: textContent }; - } else if (toType.startsWith("heading_")) { - const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6; - newElement = { type: "heading", level, text: textContent }; - } else { - return; - } - - const newBody = [...fileDetail.body]; - newBody[index] = newElement; - - setFileDetail({ ...fileDetail, body: newBody }); - updateHasLocalChanges(true); - - if (focusedElement?.index === index) { - setFocusedElement({ - index, - type: newElement.type, - preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""), - }); - } - - pendingUpdateRef.current = true; - lastSentVersionRef.current = fileDetail.version; - try { - const result = await editFile(fileId, { body: newBody, version: fileDetail.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - }, - [fileDetail, fileId, editFile, updateHasLocalChanges, focusedElement] - ); - - const handleGenerateFromElement = useCallback( - (index: number, action: string) => { - if (!fileDetail) return; - - const element = fileDetail.body[index]; - if (!element) return; - - let preview = ""; - switch (element.type) { - case "heading": - case "paragraph": - preview = element.text.slice(0, 50); - break; - case "code": - preview = element.content.slice(0, 50); - break; - case "list": - preview = element.items[0]?.slice(0, 40) || ""; - break; - default: - preview = "Element"; - } - - setFocusedElement({ - index, - type: element.type, - preview: preview + (preview.length >= 50 ? "..." : ""), - }); - - let prompt = ""; - switch (action) { - case "elaborate": - prompt = "Elaborate and expand on this content"; - break; - case "summarize": - prompt = "Summarize this content"; - break; - case "extract_actions": - prompt = "Extract action items from this content"; - break; - } - setSuggestedPrompt(prompt); - }, - [fileDetail] - ); - - // Conflict resolution handlers - const handleConflictReload = useCallback(async () => { - if (fileId) { - clearConflict(); - const detail = await fetchFile(fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - updateHasLocalChanges(false); - } - }, [fileId, clearConflict, fetchFile, updateHasLocalChanges]); - - const handleConflictForceOverwrite = useCallback(async () => { - if (fileId && fileDetail) { - clearConflict(); - const latest = await fetchFile(fileId); - if (latest) { - pendingUpdateRef.current = true; - lastSentVersionRef.current = latest.version; - try { - const result = await editFile(fileId, { body: fileDetail.body, version: latest.version }); - if (result) { - lastSavedVersionRef.current = result.version; - currentVersionRef.current = result.version; - setFileDetail(result); - updateHasLocalChanges(false); - } - } finally { - pendingUpdateRef.current = false; - lastSentVersionRef.current = null; - } - } - } - }, [fileId, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]); - - const handleRemoteUpdateRefresh = useCallback(async () => { - if (fileId) { - const detail = await fetchFile(fileId); - if (detail) { - currentVersionRef.current = detail.version; - } - setFileDetail(detail); - setRemoteUpdate(null); - setRemoteFileData(null); - updateHasLocalChanges(false); - } - }, [fileId, fetchFile, updateHasLocalChanges]); - - const handleRemoteUpdateDismiss = useCallback(() => { - setRemoteUpdate(null); - setRemoteFileData(null); - }, []); - - return ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden"> - <Masthead showTicker={false} showNav /> - - <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col"> - {error && ( - <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0"> - {error} - </div> - )} - - {fileId && fileDetail ? ( - <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> - <div className="flex-1 min-h-0 overflow-hidden"> - <FileDetail - file={fileDetail} - loading={detailLoading} - onBack={handleBack} - onSave={handleSave} - onDelete={handleDelete} - onBodyElementUpdate={handleBodyElementUpdate} - onBodyReorder={handleBodyReorder} - onBodyElementDelete={handleBodyElementDelete} - onBodyElementDuplicate={handleBodyElementDuplicate} - onConvertElement={handleConvertElement} - onGenerateFromElement={handleGenerateFromElement} - onEditingChange={updateIsActivelyEditing} - hasPendingRemoteUpdate={!!remoteUpdate} - onOverwrite={handleRemoteUpdateDismiss} - focusedElement={focusedElement} - onFocusElement={handleFocusElement} - versions={versions} - versionsLoading={versionsLoading} - selectedVersion={selectedVersion} - loadingVersion={loadingVersion} - restoring={restoring} - onSelectVersion={fetchVersion} - onRestoreVersion={handleRestoreVersion} - onClearVersionSelection={clearSelectedVersion} - /> - </div> - <div className="shrink-0"> - <CliInput - fileId={fileId} - onUpdate={handleBodyUpdate} - focusedElement={focusedElement} - onClearFocus={handleClearFocus} - suggestedPrompt={suggestedPrompt} - onClearSuggestedPrompt={() => setSuggestedPrompt(null)} - /> - </div> - </div> - ) : fileId && detailLoading ? ( - <div className="panel h-full flex items-center justify-center"> - <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> - </div> - ) : ( - <div className="panel h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - File not found - </p> - <button - onClick={handleBack} - className="px-4 py-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" - > - ← Back to Contract - </button> - </div> - </div> - )} - </main> - - {/* Conflict notification */} - {conflict?.hasConflict && ( - <ConflictNotification - onReload={handleConflictReload} - onForceOverwrite={handleConflictForceOverwrite} - onDismiss={clearConflict} - /> - )} - - {/* Remote update notification */} - {remoteUpdate && ( - <UpdateNotification - updatedBy={remoteUpdate.updatedBy} - localBody={fileDetail?.body || []} - remoteBody={remoteFileData?.body || []} - onRefresh={handleRemoteUpdateRefresh} - onDismiss={handleRemoteUpdateDismiss} - /> - )} - </div> - ); -} diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx deleted file mode 100644 index ce9ceca..0000000 --- a/makima/frontend/src/routes/contracts.tsx +++ /dev/null @@ -1,885 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { useParams, useNavigate } from "react-router"; -import { Masthead } from "../components/Masthead"; -import { ContractList } from "../components/contracts/ContractList"; -import { ContractDetail } from "../components/contracts/ContractDetail"; -import { DirectoryInput } from "../components/mesh/DirectoryInput"; -import { useContracts } from "../hooks/useContracts"; -import { useAuth } from "../contexts/AuthContext"; -import { - createTask, - getDaemonDirectories, - getRepositorySuggestions, - listContractTypes, -} from "../lib/api"; -import type { - ContractWithRelations, - ContractSummary, - ContractPhase, - ContractStatus, - ContractType, - CreateContractRequest, - RepositorySourceType, - DaemonDirectory, - RepositoryHistoryEntry, - ContractTypeTemplate, -} from "../lib/api"; - -export default function ContractsPage() { - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - return <ContractsPageContent />; -} - -function ContractsPageContent() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const { - contracts, - loading, - error, - fetchContract, - saveContract, - editContract, - removeContract, - changePhase, - addRemoteRepo, - addLocalRepo, - createManagedRepo, - removeRepo, - setRepoPrimary, - } = useContracts(); - - const [contractDetail, setContractDetail] = useState<ContractWithRelations | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [isCreating, setIsCreating] = useState(false); - const [newContractName, setNewContractName] = useState(""); - const [newContractDescription, setNewContractDescription] = useState(""); - const [contractType, setContractType] = useState<ContractType>("simple"); - const [initialPhase, setInitialPhase] = useState<ContractPhase>("plan"); - const [repoType, setRepoType] = useState<RepositorySourceType>("remote"); - const [repoName, setRepoName] = useState(""); - const [repoUrl, setRepoUrl] = useState(""); - const [repoPath, setRepoPath] = useState(""); - const [createError, setCreateError] = useState<string | null>(null); - const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); - const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); - const [contractTypes, setContractTypes] = useState<ContractTypeTemplate[]>([]); - const [contractTypesLoading, setContractTypesLoading] = useState(false); - const [localOnly, setLocalOnly] = useState(false); - - // Fetch contract types when modal opens - API returns both built-in and custom templates - useEffect(() => { - if (isCreating) { - setContractTypesLoading(true); - - listContractTypes() - .then((res) => { - setContractTypes(res.contractTypes); - setContractTypesLoading(false); - }) - .catch((err) => { - console.error("Failed to fetch contract types:", err); - // Fall back to built-in types - const builtinTypes: ContractTypeTemplate[] = [ - { - id: "simple", - name: "Simple", - description: "Plan \u2192 Execute: Simple workflow with a plan document", - phases: ["plan", "execute"], - defaultPhase: "plan", - isBuiltin: true, - }, - { - id: "specification", - name: "Specification", - description: "Research \u2192 Specify \u2192 Plan \u2192 Execute \u2192 Review: Full specification-driven development with TDD", - phases: ["research", "specify", "plan", "execute", "review"], - defaultPhase: "research", - isBuiltin: true, - }, - { - id: "execute", - name: "Execute", - description: "Execute only: Minimal workflow for immediate task execution", - phases: ["execute"], - defaultPhase: "execute", - isBuiltin: true, - }, - ]; - setContractTypes(builtinTypes); - setContractTypesLoading(false); - }); - } - }, [isCreating]); - - // Fetch repository suggestions when modal opens and repo type changes - useEffect(() => { - if (isCreating && (repoType === "remote" || repoType === "local")) { - getRepositorySuggestions(repoType, undefined, 10) - .then((res) => { - setRepoSuggestions(res.entries); - setShowRepoSuggestions(res.entries.length > 0); - }) - .catch(() => { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - }); - } else { - setRepoSuggestions([]); - setShowRepoSuggestions(false); - } - }, [isCreating, repoType]); - - // Apply a repository suggestion - const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => { - setRepoName(suggestion.name); - if (suggestion.repositoryUrl) { - setRepoUrl(suggestion.repositoryUrl); - } - if (suggestion.localPath) { - setRepoPath(suggestion.localPath); - } - setShowRepoSuggestions(false); - }, []); - - // Fetch daemon directories when "local" repo type is selected - useEffect(() => { - if (repoType === "local" && isCreating) { - getDaemonDirectories() - .then((res) => setSuggestedDirectories(res.directories)) - .catch(() => setSuggestedDirectories([])); - } - }, [repoType, isCreating]); - - // Load contract detail when ID changes - useEffect(() => { - if (id) { - setDetailLoading(true); - fetchContract(id).then((contract) => { - setContractDetail(contract); - setDetailLoading(false); - }); - } else { - setContractDetail(null); - } - }, [id, fetchContract]); - - const handleSelect = useCallback( - (contractId: string) => { - navigate(`/contracts/${contractId}`); - }, - [navigate] - ); - - const handleBack = useCallback(() => { - navigate("/contracts"); - }, [navigate]); - - const handleCreate = useCallback(() => { - setIsCreating(true); - }, []); - - // Validate repository configuration - const isRepoValid = useCallback(() => { - if (!repoName.trim()) return false; - if (repoType === "remote" && !repoUrl.trim()) return false; - if (repoType === "local" && !repoPath.trim()) return false; - return true; - }, [repoType, repoName, repoUrl, repoPath]); - - const handleCreateSubmit = useCallback(async () => { - if (!newContractName.trim()) return; - if (!isRepoValid()) { - setCreateError("Repository configuration is required"); - return; - } - - setCreateError(null); - - // 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, - // For custom templates, send templateId instead of contractType - contractType: isCustomTemplate ? undefined : contractType, - templateId: isCustomTemplate ? contractType : undefined, - initialPhase: initialPhase !== defaultPhaseForType ? initialPhase : undefined, - localOnly: localOnly || undefined, - }; - - try { - const contract = await saveContract(data); - if (contract) { - // Add the repository after contract creation - try { - if (repoType === "remote") { - await addRemoteRepo(contract.id, { - name: repoName.trim(), - repositoryUrl: repoUrl.trim(), - isPrimary: true, - }); - } else if (repoType === "local") { - await addLocalRepo(contract.id, { - name: repoName.trim(), - localPath: repoPath.trim(), - isPrimary: true, - }); - } else if (repoType === "managed") { - await createManagedRepo(contract.id, { - name: repoName.trim(), - isPrimary: true, - }); - } - } catch (repoError) { - console.error("Failed to add repository:", repoError); - // Still navigate to the contract - repo can be added later - } - - // Clear form state - setIsCreating(false); - setNewContractName(""); - setNewContractDescription(""); - setContractType("simple"); - setInitialPhase("plan"); - setRepoType("remote"); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - setLocalOnly(false); - navigate(`/contracts/${contract.id}`); - } - } catch (err) { - setCreateError(err instanceof Error ? err.message : "Failed to create contract"); - } - }, [ - newContractName, - newContractDescription, - contractType, - contractTypes, - initialPhase, - repoType, - repoName, - repoUrl, - repoPath, - isRepoValid, - saveContract, - addRemoteRepo, - addLocalRepo, - createManagedRepo, - navigate, - ]); - - const handleCreateCancel = useCallback(() => { - setIsCreating(false); - setNewContractName(""); - setNewContractDescription(""); - setContractType("simple"); - setInitialPhase("plan"); - setRepoType("remote"); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - setLocalOnly(false); - setCreateError(null); - }, []); - - const handleUpdate = useCallback( - async (name: string, description: string) => { - if (contractDetail) { - const updated = await editContract(contractDetail.id, { - name, - description: description || undefined, - version: contractDetail.version, - }); - if (updated) { - // Refresh detail - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - } - }, - [contractDetail, editContract, fetchContract] - ); - - const handleDelete = useCallback(async () => { - if (contractDetail && confirm("Are you sure you want to delete this contract?")) { - const success = await removeContract(contractDetail.id); - if (success) { - navigate("/contracts"); - } - } - }, [contractDetail, removeContract, navigate]); - - const handlePhaseChange = useCallback( - async (phase: ContractPhase) => { - if (contractDetail) { - const updated = await changePhase(contractDetail.id, phase); - if (updated) { - // Refresh detail - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - } - }, - [contractDetail, changePhase, fetchContract] - ); - - const handleStatusChange = useCallback( - async (status: ContractStatus) => { - if (contractDetail) { - const updated = await editContract(contractDetail.id, { - status, - version: contractDetail.version, - }); - if (updated) { - // Refresh detail - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - } - }, - [contractDetail, editContract, fetchContract] - ); - - // Repository handlers - const handleAddRemoteRepo = useCallback( - async (name: string, url: string, isPrimary: boolean) => { - if (contractDetail) { - await addRemoteRepo(contractDetail.id, { name, repositoryUrl: url, isPrimary }); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, addRemoteRepo, fetchContract] - ); - - const handleAddLocalRepo = useCallback( - async (name: string, path: string, isPrimary: boolean) => { - if (contractDetail) { - await addLocalRepo(contractDetail.id, { name, localPath: path, isPrimary }); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, addLocalRepo, fetchContract] - ); - - const handleCreateManagedRepo = useCallback( - async (name: string, isPrimary: boolean) => { - if (contractDetail) { - await createManagedRepo(contractDetail.id, { name, isPrimary }); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, createManagedRepo, fetchContract] - ); - - const handleDeleteRepo = useCallback( - async (repoId: string) => { - if (contractDetail) { - await removeRepo(contractDetail.id, repoId); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, removeRepo, fetchContract] - ); - - const handleSetRepoPrimary = useCallback( - async (repoId: string) => { - if (contractDetail) { - await setRepoPrimary(contractDetail.id, repoId); - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, - [contractDetail, setRepoPrimary, fetchContract] - ); - - // Refresh contract detail (used after file/task operations) - const handleRefresh = useCallback(async () => { - if (contractDetail) { - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - } - }, [contractDetail, fetchContract]); - - // File/task navigation handlers - const handleFileSelect = useCallback( - (fileId: string) => { - if (contractDetail) { - navigate(`/contracts/${contractDetail.id}/files/${fileId}`); - } - }, - [navigate, contractDetail] - ); - - const handleTaskSelect = useCallback( - (taskId: string) => { - navigate(`/exec/${taskId}`); - }, - [navigate] - ); - - // Create task within contract context - const handleTaskCreate = useCallback( - async (name: string, plan: string, repositoryUrl?: string) => { - if (!contractDetail) return; - try { - // Create the task with contract_id (task is automatically associated) - const task = await createTask({ - contractId: contractDetail.id, - name, - plan, - repositoryUrl, - }); - // Refresh contract detail to show new task - const refreshed = await fetchContract(contractDetail.id); - setContractDetail(refreshed); - // Navigate to the new task - navigate(`/exec/${task.id}`); - } catch (e) { - console.error("Failed to create task:", e); - alert(e instanceof Error ? e.message : "Failed to create task"); - } - }, - [contractDetail, fetchContract, navigate] - ); - - // Context menu handlers for ContractList - const handleContextMarkComplete = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "completed", version: contract.version }); - }, - [editContract] - ); - - const handleContextMarkActive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "active", version: contract.version }); - }, - [editContract] - ); - - const handleContextArchive = useCallback( - async (contract: ContractSummary) => { - await editContract(contract.id, { status: "archived", version: contract.version }); - }, - [editContract] - ); - - const handleContextDelete = useCallback( - async (contract: ContractSummary) => { - if (confirm(`Are you sure you want to delete "${contract.name}"?`)) { - const success = await removeContract(contract.id); - if (success && contract.id === id) { - navigate("/contracts"); - } - } - }, - [removeContract, id, navigate] - ); - - const handleContextGoToSupervisor = useCallback( - (contract: ContractSummary) => { - if (contract.supervisorTaskId) { - navigate(`/exec/${contract.supervisorTaskId}`); - } - }, - [navigate] - ); - - return ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex overflow-hidden" style={{ height: "calc(100vh - 80px)" }}> - {/* Left: Contract list */} - <div className="w-[350px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col"> - <ContractList - contracts={contracts} - loading={loading} - onSelect={handleSelect} - onCreate={handleCreate} - selectedId={id} - onMarkComplete={handleContextMarkComplete} - onMarkActive={handleContextMarkActive} - onArchive={handleContextArchive} - onDelete={handleContextDelete} - onGoToSupervisor={handleContextGoToSupervisor} - /> - </div> - - {/* Right: Detail or Create */} - <div className="flex-1 overflow-hidden flex flex-col min-h-0"> - {error && ( - <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> - {error} - </div> - )} - - {/* Contract detail, creation form, or empty state */} - <div className="flex-1 min-h-0 overflow-hidden"> - {isCreating ? ( - <div className="p-4 max-w-lg overflow-y-auto h-full bg-[#0a1628]"> - <h3 className="font-mono text-[10px] text-[#9bc3ff] uppercase tracking-wide mb-4"> - Create Contract - </h3> - - {createError && ( - <div className="mb-4 p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> - {createError} - </div> - )} - - <div className="space-y-4"> - {/* Contract name */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Contract Name - </label> - <input - type="text" - value={newContractName} - onChange={(e) => setNewContractName(e.target.value)} - placeholder="Contract name" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - autoFocus - /> - </div> - - {/* Description */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Description (optional) - </label> - <textarea - value={newContractDescription} - onChange={(e) => setNewContractDescription(e.target.value)} - placeholder="Describe what this contract is for..." - rows={2} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc] resize-none" - /> - </div> - - {/* Contract Type */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Contract Type - </label> - {contractTypesLoading ? ( - <div className="flex items-center justify-center py-4"> - <span className="font-mono text-xs text-[#8b949e]">Loading contract types...</span> - </div> - ) : ( - <> - <div className="flex gap-2"> - {contractTypes.map((type) => ( - <button - key={type.id} - type="button" - onClick={() => { - setContractType(type.id as ContractType); - setInitialPhase(type.defaultPhase as ContractPhase); - }} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - contractType === type.id - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - {type.name} - </button> - ))} - </div> - <p className="mt-1 font-mono text-xs text-[#8b949e]"> - {contractTypes.find((t) => t.id === contractType)?.description || - "Select a contract type"} - </p> - </> - )} - </div> - - {/* Starting Phase */} - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Starting Phase - </label> - <select - value={initialPhase} - onChange={(e) => setInitialPhase(e.target.value as ContractPhase)} - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - > - {(() => { - const template = contractTypes.find((t) => t.id === contractType); - return (template?.phases || []).map((phase) => { - const displayName = template?.phaseNames?.[phase] || (phase.charAt(0).toUpperCase() + phase.slice(1)); - return ( - <option key={phase} value={phase}> - {displayName} - </option> - ); - }); - })()} - </select> - <p className="mt-1 font-mono text-xs text-[#8b949e]"> - {contractType === "simple" - ? "Start in Plan to define what to build, or Execute if already planned" - : "Skip earlier phases if you already have requirements defined"} - </p> - </div> - - {/* Local-Only Mode */} - <div className="space-y-2"> - <div className="flex items-center space-x-3"> - <button - type="button" - onClick={() => setLocalOnly(!localOnly)} - className={`w-5 h-5 flex items-center justify-center border transition-colors ${ - localOnly - ? "bg-[#0f3c78] border-[#75aafc] text-[#dbe7ff]" - : "bg-[#0d1b2d] border-[rgba(117,170,252,0.2)] text-transparent" - }`} - > - {localOnly && ( - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="3" - strokeLinecap="round" - strokeLinejoin="round" - className="w-3 h-3" - > - <polyline points="20 6 9 17 4 12" /> - </svg> - )} - </button> - <label - className="font-mono text-sm text-[#dbe7ff] cursor-pointer select-none" - onClick={() => setLocalOnly(!localOnly)} - > - Local-Only Mode - </label> - </div> - <p className="font-mono text-xs text-[#8b949e] pl-8"> - When enabled, tasks won't automatically push to remote or create PRs. - Use patch files to export changes. - </p> - </div> - - {/* Repository Configuration */} - <div className="border-t border-[rgba(117,170,252,0.2)] pt-4"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-3"> - Repository Configuration (Required) - </label> - - {/* Repository type selector */} - <div className="flex gap-2 mb-3"> - <button - type="button" - onClick={() => setRepoType("remote")} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - repoType === "remote" - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Remote - </button> - <button - type="button" - onClick={() => setRepoType("local")} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - repoType === "local" - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Local - </button> - <button - type="button" - onClick={() => setRepoType("managed")} - className={`flex-1 px-3 py-2 font-mono text-xs uppercase transition-colors ${ - repoType === "managed" - ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" - : "bg-[#0d1b2d] text-[#8b949e] border border-[rgba(117,170,252,0.2)] hover:border-[#75aafc]" - }`} - > - Managed - </button> - </div> - - {/* Repository suggestions */} - {showRepoSuggestions && repoSuggestions.length > 0 && ( - <div className="mb-3"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Recent Repositories - </label> - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> - {repoSuggestions.map((suggestion) => ( - <button - key={suggestion.id} - type="button" - onClick={() => applyRepoSuggestion(suggestion)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> - <span className="text-[10px] text-[#556677] ml-2"> - {suggestion.useCount}× - </span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {repoType === "local" ? suggestion.localPath : suggestion.repositoryUrl} - </div> - </button> - ))} - </div> - </div> - )} - - {/* Repository name */} - <div className="mb-3"> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Repository Name - </label> - <input - type="text" - value={repoName} - onChange={(e) => setRepoName(e.target.value)} - placeholder="e.g., my-project" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - /> - </div> - - {/* Repository URL (for remote) */} - {repoType === "remote" && ( - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Repository URL - </label> - <input - type="text" - value={repoUrl} - onChange={(e) => setRepoUrl(e.target.value)} - placeholder="https://github.com/user/repo.git" - className="w-full px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)] text-[12px] font-mono text-white focus:outline-none focus:border-[#75aafc]" - /> - </div> - )} - - {/* Repository path (for local) */} - {repoType === "local" && ( - <div> - <label className="block text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide mb-1"> - Local Path - </label> - <DirectoryInput - value={repoPath} - onChange={setRepoPath} - suggestions={suggestedDirectories} - placeholder="/path/to/repository" - /> - </div> - )} - - {/* Managed description */} - {repoType === "managed" && ( - <p className="font-mono text-xs text-[#8b949e]"> - A managed repository will be created automatically by the daemon. - </p> - )} - </div> - - {/* Actions */} - <div className="flex gap-2 justify-end pt-2"> - <button - onClick={handleCreateCancel} - className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - Cancel - </button> - <button - onClick={handleCreateSubmit} - disabled={!newContractName.trim() || !isRepoValid()} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[rgba(117,170,252,0.2)] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - > - Create - </button> - </div> - </div> - </div> - ) : contractDetail ? ( - <ContractDetail - contract={contractDetail} - loading={detailLoading} - onBack={handleBack} - onUpdate={handleUpdate} - onDelete={handleDelete} - onPhaseChange={handlePhaseChange} - onStatusChange={handleStatusChange} - onFileSelect={handleFileSelect} - onTaskSelect={handleTaskSelect} - onTaskCreate={handleTaskCreate} - onRefresh={handleRefresh} - onAddRemoteRepo={handleAddRemoteRepo} - onAddLocalRepo={handleAddLocalRepo} - onCreateManagedRepo={handleCreateManagedRepo} - onDeleteRepo={handleDeleteRepo} - onSetRepoPrimary={handleSetRepoPrimary} - /> - ) : ( - <div className="panel h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - Select a contract or create a new one - </p> - <button - onClick={handleCreate} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + New Contract - </button> - </div> - </div> - )} - </div> - </div> - </main> - </div> - ); -} diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 7b0a89b..a3ea969 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -1530,13 +1530,17 @@ export default function DocumentDirectivesPage() { : null; return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + // h-screen + overflow-hidden so the page itself never scrolls; the + // sidebar and editor pane each manage their own scroll via flex-1 + // children with overflow-y-auto. Previously we set + // height: calc(100vh - 80px) on <main>, which assumed an 80px masthead + // and quietly clipped content when the masthead was taller (or pushed + // the page below the viewport on shorter screens, which made the + // whole page scroll instead of the sidebar/editor independently). + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> - <main - className="flex-1 flex overflow-hidden" - style={{ height: "calc(100vh - 80px)" }} - > - {/* Left: file-tree sidebar */} + <main className="flex-1 flex min-h-0 overflow-hidden"> + {/* Left: file-tree sidebar — independent scroll. */} <div className="w-[260px] shrink-0 border-r border-dashed border-[rgba(117,170,252,0.2)] overflow-hidden flex flex-col bg-[#091428]"> <DocumentSidebar directives={directives} diff --git a/makima/frontend/src/routes/tmp.tsx b/makima/frontend/src/routes/tmp.tsx index 69f13a2..c0c7365 100644 --- a/makima/frontend/src/routes/tmp.tsx +++ b/makima/frontend/src/routes/tmp.tsx @@ -53,7 +53,7 @@ export default function TmpTaskPage() { if (authLoading) { return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> <main className="flex-1 flex items-center justify-center"> <p className="text-[#7788aa] font-mono text-sm">Loading...</p> @@ -63,12 +63,9 @@ export default function TmpTaskPage() { } return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> + <div className="relative z-10 h-screen flex flex-col bg-[#0a1628] overflow-hidden"> <Masthead showNav /> - <main - className="flex-1 flex flex-col overflow-hidden" - style={{ height: "calc(100vh - 80px)" }} - > + <main className="flex-1 flex flex-col min-h-0 overflow-hidden"> {/* Breadcrumb echoing the document-mode header style. */} <div className="px-6 py-3 border-b border-dashed border-[rgba(117,170,252,0.2)]"> <div className="flex items-center gap-2 text-[10px] font-mono uppercase tracking-wide text-[#7788aa]"> diff --git a/makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql b/makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql new file mode 100644 index 0000000..a587288 --- /dev/null +++ b/makima/migrations/20260501100000_tmp_directive_and_clear_orphans.sql @@ -0,0 +1,39 @@ +-- Tmp directive system: every task must belong to a directive going forward. +-- +-- Background: the unified-surface UI previously surfaced "orphan" tasks +-- (tasks with directive_id NULL) under a synthetic /tmp/ folder. That +-- accumulated stale junk over time and made the UI noisy. The new model: +-- +-- * Add `is_tmp` to directives — at most one per owner, marks the +-- special "scratchpad" directive that holds otherwise-orphan tasks. +-- * Delete every existing orphan task. The user explicitly asked for +-- a clean slate: "ALSO there are TOO MANY old tasks in tmp, we need +-- to remove all of them as well." +-- * Going forward, ephemeral / standalone task creation paths attach +-- to the caller's tmp directive (auto-created on first use by the +-- repository helper, not by this migration — owners may not exist +-- yet at migration time, but every owner gets one as soon as a +-- standalone task is requested). +-- * A 30-day expiry sweep in the directive reconciler deletes tasks +-- in tmp directives once they age out. + +-- 1. New flag column on directives. Default false; only set true on the +-- auto-created scratchpad directive. +ALTER TABLE directives + ADD COLUMN is_tmp BOOLEAN NOT NULL DEFAULT false; + +-- Partial unique index — at most ONE tmp directive per owner. +CREATE UNIQUE INDEX idx_directives_owner_tmp_unique + ON directives(owner_id) + WHERE is_tmp; + +-- 2. Clear out every existing orphan task. Per the user's spec these are +-- discardable scratch work; pre-existing valuable tasks are already +-- attached to a directive and will not be touched. +-- +-- Cascades: task_events delete via FK; daemon links go to NULL; nothing +-- in the contracts/directive_steps tables references orphan tasks (a +-- contract-backed step always has a directive_id by construction). +DELETE FROM tasks + WHERE directive_id IS NULL + AND parent_task_id IS NULL; diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index df3e8e7..338d8f9 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -4,10 +4,9 @@ use std::io::{self, Read}; use std::path::Path; use std::sync::Arc; -use makima::daemon::api::{ApiClient, CreateContractRequest}; +use makima::daemon::api::ApiClient; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, ContractCommand, - DirectiveCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -27,8 +26,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { match cli.command { Commands::Server(args) => run_server(args).await, Commands::Daemon(args) => run_daemon(args).await, - Commands::Supervisor(cmd) => run_supervisor(cmd).await, - Commands::Contract(cmd) => run_contract(cmd).await, Commands::Directive(cmd) => run_directive(cmd).await, Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, @@ -309,383 +306,6 @@ async fn run_daemon( Ok(()) } -/// Run supervisor commands. -async fn run_supervisor( - cmd: SupervisorCommand, -) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - use makima::daemon::api::supervisor::*; - - match cmd { - SupervisorCommand::Tasks(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.supervisor_tasks(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Tree(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.supervisor_tree(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Spawn(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Creating task: {}...", args.name); - let req = SpawnTaskRequest { - name: args.name, - plan: args.plan, - contract_id: args.common.contract_id, - parent_task_id: args.parent, - checkpoint_sha: args.checkpoint, - }; - let result = client.supervisor_spawn(req).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Wait(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!( - "Waiting for task {} (timeout: {}s, poll interval: {}s)...", - args.task_id, args.timeout, args.poll_interval - ); - - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_secs(args.timeout as u64); - let poll_interval = std::time::Duration::from_secs(args.poll_interval); - let server_wait_timeout = 30i32; // Short timeout for server-side wait - - loop { - // Check if we've exceeded the total timeout - let remaining = timeout_duration.saturating_sub(start_time.elapsed()); - if remaining.is_zero() { - eprintln!("Timeout reached after {}s", args.timeout); - let result = client.supervisor_get_task(args.task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - break; - } - - // Try server-side wait with short timeout - let wait_timeout = std::cmp::min(server_wait_timeout, remaining.as_secs() as i32); - - match client.supervisor_wait(args.task_id, wait_timeout).await { - Ok(result) => { - if let Some(completed) = result.0.get("completed").and_then(|c| c.as_bool()) { - if completed { - println!("{}", serde_json::to_string(&result.0)?); - break; - } - } - // Not completed yet, continue loop - eprintln!("Task still running (elapsed: {:?})", start_time.elapsed()); - } - Err(e) => { - eprintln!("Warning: Server wait failed: {}. Falling back to polling...", e); - // Fall back to simple status poll - if let Ok(result) = client.supervisor_get_task(args.task_id).await { - if let Some(status) = result.0.get("status").and_then(|s| s.as_str()) { - if status == "done" || status == "failed" || status == "merged" { - let wait_response = serde_json::json!({ - "taskId": args.task_id, - "status": status, - "completed": true, - "outputSummary": result.0.get("progressSummary") - }); - println!("{}", serde_json::to_string(&wait_response)?); - break; - } - } - } - } - } - - // Small delay before retrying - tokio::time::sleep(poll_interval).await; - } - } - SupervisorCommand::ReadFile(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .supervisor_read_file(args.task_id, &args.file_path) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Branch(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Creating branch: {}...", args.name); - let result = client.supervisor_branch(&args.name, args.from).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Merge(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Merging task {}...", args.task_id); - let result = client - .supervisor_merge(args.task_id, args.to, args.squash) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Pr(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Creating PR for branch {}...", args.branch); - let body = args.body.as_deref().unwrap_or(""); - let result = client - .supervisor_pr(&args.branch, &args.title, body) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Diff(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.supervisor_diff(args.task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Checkpoint(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let task_id = args - .common - .self_task_id - .ok_or("MAKIMA_TASK_ID is required for checkpoint")?; - let result = client - .supervisor_checkpoint(task_id, &args.message) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Checkpoints(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let task_id = args.self_task_id.ok_or("MAKIMA_TASK_ID is required")?; - let result = client.supervisor_checkpoints(task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Status(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.supervisor_status(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Ask(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Asking user: {}...", args.question); - let choices = args - .choices - .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) - .unwrap_or_default(); - let result = client - .supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::AdvancePhase(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - if args.confirmed { - eprintln!("Advancing contract to phase: {} (confirmed)...", args.phase); - } else { - eprintln!("Requesting phase advance to: {} (use --confirmed to proceed)...", args.phase); - } - let result = client - .supervisor_advance_phase(args.common.contract_id, &args.phase, args.confirmed) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Task(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.supervisor_get_task(args.target_task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::Output(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client.supervisor_get_task_output(args.target_task_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - SupervisorCommand::TaskHistory(args) => { - eprintln!( - "Task history for {} (limit: {:?}, format: {})", - args.task_id, args.limit, args.format - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" GET /api/v1/mesh/tasks/{}/conversation", args.task_id); - } - SupervisorCommand::TaskCheckpoints(args) => { - eprintln!( - "Task checkpoints for {} (with_diff: {})", - args.task_id, args.with_diff - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" GET /api/v1/mesh/tasks/{}/checkpoints", args.task_id); - } - SupervisorCommand::Resume(args) => { - eprintln!( - "Resume supervisor for contract {} (mode: {}, checkpoint: {:?})", - args.common.contract_id, args.mode, args.checkpoint - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!( - " POST /api/v1/contracts/{}/supervisor/resume", - args.common.contract_id - ); - } - SupervisorCommand::TaskResumeFrom(args) => { - eprintln!( - "Resume task {} from checkpoint {} with plan: {}", - args.task_id, args.checkpoint, args.plan - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!( - " POST /api/v1/mesh/tasks/{}/checkpoints/{}/resume", - args.task_id, args.checkpoint - ); - } - SupervisorCommand::TaskRewind(args) => { - eprintln!( - "Rewind task {} to checkpoint {} (preserve: {}, branch: {:?})", - args.task_id, args.checkpoint, args.preserve, args.branch_name - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" POST /api/v1/mesh/tasks/{}/rewind", args.task_id); - } - SupervisorCommand::TaskFork(args) => { - eprintln!( - "Fork task {} from checkpoint {} as '{}' with plan: {}", - args.task_id, args.checkpoint, args.name, args.plan - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!(" POST /api/v1/mesh/tasks/{}/fork", args.task_id); - } - SupervisorCommand::RewindConversation(args) => { - eprintln!( - "Rewind conversation for contract {} (by: {:?}, to: {:?}, rewind_code: {})", - args.common.contract_id, args.by_messages, args.to_message, args.rewind_code - ); - eprintln!("CLI integration not yet implemented. Use the API directly:"); - eprintln!( - " POST /api/v1/contracts/{}/supervisor/conversation/rewind", - args.common.contract_id - ); - } - SupervisorCommand::Complete(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!("Marking contract {} as complete...", args.common.contract_id); - match client.supervisor_complete(args.common.contract_id).await { - Ok(_) => { - println!(r#"{{"success": true, "message": "Contract marked as complete"}}"#); - } - Err(e) => { - eprintln!("Error: {}", e); - println!(r#"{{"success": false, "error": "{}"}}"#, e); - std::process::exit(1); - } - } - } - SupervisorCommand::ResumeContract(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - eprintln!("Resuming contract {}...", args.contract_id); - let result = client.supervisor_resume_contract(args.contract_id).await?; - println!("{}", serde_json::to_string(&serde_json::json!({ - "success": true, - "message": "Contract resumed", - "contract": result.0 - }))?); - } - SupervisorCommand::MarkDeliverable(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - eprintln!( - "Marking deliverable '{}' as complete for contract {}...", - args.deliverable_id, args.common.contract_id - ); - let result = client - .supervisor_mark_deliverable( - args.common.contract_id, - &args.deliverable_id, - args.phase.as_deref(), - ) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - } - - Ok(()) -} - -/// Run contract commands. -async fn run_contract( - cmd: ContractCommand, -) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - match cmd { - ContractCommand::Status(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_status(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Checklist(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_checklist(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Goals(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_goals(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Files(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_files(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::File(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .contract_file(args.common.contract_id, args.file_id) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::Report(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let result = client - .contract_report(args.common.contract_id, &args.message, args.common.task_id) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::SuggestAction(args) => { - let client = ApiClient::new(args.api_url, args.api_key)?; - let result = client.contract_suggest_action(args.contract_id).await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::CompletionAction(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - let files = args.files.map(|f| { - f.split(',') - .map(|s| s.trim().to_string()) - .collect::<Vec<_>>() - }); - let result = client - .contract_completion_action( - args.common.contract_id, - args.common.task_id, - files, - args.lines_added, - args.lines_removed, - args.code, - ) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::UpdateFile(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - // Read content from stdin - let mut content = String::new(); - io::stdin().read_to_string(&mut content)?; - let result = client - .contract_update_file(args.common.contract_id, args.file_id, &content) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - ContractCommand::CreateFile(args) => { - let client = ApiClient::new(args.common.api_url, args.common.api_key)?; - // Read content from stdin - let mut content = String::new(); - io::stdin().read_to_string(&mut content)?; - let result = client - .contract_create_file(args.common.contract_id, &args.name, &content) - .await?; - println!("{}", serde_json::to_string(&result.0)?); - } - } - - Ok(()) -} /// Run directive commands. async fn run_directive( @@ -1380,68 +1000,14 @@ async fn run_tui_loop( app.ws_state = WsConnectionState::Disconnected; } } - Action::PerformCreateContract { name, description, contract_type, repository_url } => { - // Create the contract via API - let req = CreateContractRequest { - name: name.clone(), - description: if description.is_empty() { None } else { Some(description) }, - contract_type: Some(contract_type), - initial_phase: None, - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - }; - - match client.create_contract(req).await { - Ok(result) => { - let contract_name = result.0.get("name") - .and_then(|v| v.as_str()) - .unwrap_or(&name) - .to_string(); - let contract_id = result.0.get("id") - .and_then(|v| v.as_str()) - .and_then(|s| uuid::Uuid::parse_str(s).ok()); - - // Add repository if provided - if let (Some(repo_url), Some(cid)) = (repository_url.as_ref(), contract_id) { - if !repo_url.is_empty() { - // Extract repo name from URL (e.g., "owner/repo" from GitHub URL) - let repo_name = extract_repo_name(repo_url); - match client.add_remote_repository(cid, &repo_name, repo_url, true).await { - Ok(_) => { - app.status_message = Some(format!( - "Created contract '{}' with repository", - contract_name - )); - } - Err(e) => { - app.status_message = Some(format!( - "Created contract but failed to add repository: {}", - e - )); - } - } - } else { - app.status_message = Some(format!("Created contract: {}", contract_name)); - } - } else { - app.status_message = Some(format!("Created contract: {}", contract_name)); - } - - // Refresh the contracts list - match load_contracts(client).await { - Ok(items) => app.set_items(items), - Err(e) => { - let msg = app.status_message.take().unwrap_or_default(); - app.status_message = Some(format!("{} (refresh failed: {})", msg, e)); - } - } - } - Err(e) => { - app.status_message = Some(format!("Create failed: {}", e)); - } - } + Action::PerformCreateContract { name: _, description: _, contract_type: _, repository_url: _ } => { + // Contracts removed in Phase 5 — directives are + // the only way to organise multi-task work now. + // The TUI's contract create form is dead code + // pending a wider TUI refresh. + app.status_message = Some( + "Contracts have been removed. Use directives instead.".to_string() + ); } Action::LoadRepoSuggestions => { // Load repository suggestions for the create form diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs deleted file mode 100644 index a443b85..0000000 --- a/makima/src/daemon/cli/contract.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Contract subcommand - task-contract interaction commands. - -use clap::Args; -use uuid::Uuid; - -/// Common arguments for contract commands. -#[derive(Args, Debug, Clone)] -pub struct ContractArgs { - /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] - pub api_url: String, - - /// API key for authentication - #[arg(long, env = "MAKIMA_API_KEY", global = true)] - pub api_key: String, - - /// Current task ID (optional) - #[arg(long, env = "MAKIMA_TASK_ID", global = true)] - pub task_id: Option<Uuid>, - - /// Contract ID - #[arg(long, env = "MAKIMA_CONTRACT_ID", global = true)] - pub contract_id: Uuid, -} - -/// Arguments for file command (get specific file). -#[derive(Args, Debug)] -pub struct FileArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// File ID to retrieve - pub file_id: Uuid, -} - -/// Arguments for report command. -#[derive(Args, Debug)] -pub struct ReportArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// Progress message - pub message: String, -} - -/// Arguments for completion-action command. -#[derive(Args, Debug)] -pub struct CompletionActionArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// Comma-separated list of modified files - #[arg(long)] - pub files: Option<String>, - - /// Number of lines added - #[arg(long, default_value = "0")] - pub lines_added: i32, - - /// Number of lines removed - #[arg(long, default_value = "0")] - pub lines_removed: i32, - - /// Whether there are code changes - #[arg(long)] - pub code: bool, -} - -/// Arguments for update-file command. -#[derive(Args, Debug)] -pub struct UpdateFileArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// File ID to update - pub file_id: Uuid, -} - -/// Arguments for create-file command. -#[derive(Args, Debug)] -pub struct CreateFileArgs { - #[command(flatten)] - pub common: ContractArgs, - - /// Name of the new file - pub name: String, -} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 7affc55..b01c161 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -1,21 +1,17 @@ //! Command-line interface for the makima CLI. pub mod config; -pub mod contract; pub mod daemon; pub mod directive; pub mod server; -pub mod supervisor; pub mod view; use clap::{Parser, Subcommand}; pub use config::CliConfig; -pub use contract::ContractArgs; pub use daemon::DaemonArgs; pub use directive::DirectiveArgs; pub use server::ServerArgs; -pub use supervisor::SupervisorArgs; pub use view::ViewArgs; /// Makima - unified CLI for server, daemon, and task management. @@ -35,28 +31,11 @@ pub enum Commands { /// Run the daemon (connect to server, manage tasks) Daemon(DaemonArgs), - /// Supervisor commands for contract orchestration - #[command(subcommand)] - Supervisor(SupervisorCommand), - - /// Contract commands for task-contract interaction - #[command(subcommand)] - Contract(ContractCommand), - /// Directive commands for DAG-based project management #[command(subcommand)] Directive(DirectiveCommand), - /// Interactive TUI browser for contracts and tasks - /// - /// Provides a drill-down interface for browsing contracts, viewing their - /// tasks, and streaming real-time task output. - /// - /// Keyboard shortcuts: - /// ↑/k: Move up ↓/j: Move down Enter/l: Drill in - /// Esc/h: Go back /: Search q: Quit - /// e: Edit d: Delete c: cd to worktree - /// n: New contract + /// Interactive TUI browser for directives and tasks View(ViewArgs), /// Configure CLI settings (API key, server URL) @@ -86,121 +65,8 @@ pub enum ConfigCommand { Path, } -/// Supervisor subcommands for contract orchestration. -#[derive(Subcommand, Debug)] -pub enum SupervisorCommand { - /// List all tasks in the contract - Tasks(SupervisorArgs), - - /// Get the task tree structure - Tree(SupervisorArgs), - - /// Create and start a new task - Spawn(supervisor::SpawnArgs), - - /// Wait for a task to complete - Wait(supervisor::WaitArgs), - - /// Read a file from a task's worktree - ReadFile(supervisor::ReadFileArgs), - - /// Create a git branch - Branch(supervisor::BranchArgs), - - /// Merge a task's changes to a branch - Merge(supervisor::MergeArgs), - - /// Create a pull request - Pr(supervisor::PrArgs), - - /// View task diff - Diff(supervisor::DiffArgs), - - /// Create a checkpoint - Checkpoint(supervisor::CheckpointArgs), - - /// List checkpoints - Checkpoints(SupervisorArgs), - - /// Get contract status - Status(SupervisorArgs), - - /// Advance the contract to the next phase - AdvancePhase(supervisor::AdvancePhaseArgs), - - /// Ask a question and wait for user feedback - Ask(supervisor::AskArgs), - - /// Get individual task details - Task(supervisor::GetTaskArgs), - - /// Get task output/claude log - Output(supervisor::GetTaskOutputArgs), - - /// View task conversation history - TaskHistory(supervisor::TaskHistoryArgs), - - /// List task checkpoints (with optional diff) - TaskCheckpoints(supervisor::TaskCheckpointsArgs), - - /// Resume supervisor after interruption - Resume(supervisor::ResumeArgs), - - /// Resume task from checkpoint - TaskResumeFrom(supervisor::TaskResumeFromArgs), - - /// Rewind task code to checkpoint - TaskRewind(supervisor::TaskRewindArgs), - - /// Fork task from historical point - TaskFork(supervisor::TaskForkArgs), - - /// Rewind supervisor conversation - RewindConversation(supervisor::ConversationRewindArgs), - - /// Mark the contract as complete and stop the supervisor - Complete(supervisor::CompleteArgs), - - /// Resume a completed contract (reactivate it) - ResumeContract(supervisor::ResumeContractArgs), - - /// Mark a deliverable as complete - MarkDeliverable(supervisor::MarkDeliverableArgs), -} - -/// Contract subcommands for task-contract interaction. -#[derive(Subcommand, Debug)] -pub enum ContractCommand { - /// Get contract status - Status(ContractArgs), - - /// Get the phase checklist - Checklist(ContractArgs), - - /// Get contract goals - Goals(ContractArgs), - - /// List contract files - Files(ContractArgs), - - /// Get a specific file's content - File(contract::FileArgs), - - /// Report progress on the contract - Report(contract::ReportArgs), - - /// Get suggested next action - SuggestAction(ContractArgs), - - /// Get completion recommendation - CompletionAction(contract::CompletionActionArgs), - - /// Update a file (reads content from stdin) - UpdateFile(contract::UpdateFileArgs), - - /// Create a new file (reads content from stdin) - CreateFile(contract::CreateFileArgs), -} +// SupervisorCommand and ContractCommand removed in Phase 5 — contracts +// subsystem is gone. See cli/contract.rs and cli/supervisor.rs deletion. /// Directive subcommands for DAG-based project management. #[derive(Subcommand, Debug)] diff --git a/makima/src/daemon/cli/supervisor.rs b/makima/src/daemon/cli/supervisor.rs deleted file mode 100644 index 82d3900..0000000 --- a/makima/src/daemon/cli/supervisor.rs +++ /dev/null @@ -1,448 +0,0 @@ -//! Supervisor subcommand - contract orchestration commands. - -use clap::Args; -use uuid::Uuid; - -/// Common arguments for supervisor commands. -#[derive(Args, Debug, Clone)] -pub struct SupervisorArgs { - /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] - pub api_url: String, - - /// API key for authentication - #[arg(long, env = "MAKIMA_API_KEY")] - pub api_key: String, - - /// Current task ID (optional) - the supervisor's own task ID - #[arg(long, env = "MAKIMA_TASK_ID")] - pub self_task_id: Option<Uuid>, - - /// Contract ID - #[arg(long, env = "MAKIMA_CONTRACT_ID")] - pub contract_id: Uuid, -} - -/// Arguments for spawn command. -#[derive(Args, Debug)] -pub struct SpawnArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Name of the task - #[arg(index = 1)] - pub name: String, - - /// Plan/description for the task - #[arg(index = 2)] - pub plan: String, - - /// Parent task ID to branch from - #[arg(long)] - pub parent: Option<Uuid>, - - /// Checkpoint SHA to start from - #[arg(long)] - pub checkpoint: Option<String>, - - /// Repository URL (local path or remote URL). If not provided, will try to detect from current directory. - #[arg(long)] - pub repo: Option<String>, -} - -/// Arguments for wait command. -#[derive(Args, Debug)] -pub struct WaitArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to wait for - #[arg(index = 1)] - pub task_id: Uuid, - - /// Timeout in seconds (total wait time) - #[arg(index = 2, default_value = "300")] - pub timeout: i32, - - /// Polling interval in seconds (how often to check task status via client-side polling) - #[arg(long, default_value = "5")] - pub poll_interval: u64, -} - -/// Arguments for read-file command. -#[derive(Args, Debug)] -pub struct ReadFileArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to read from - #[arg(index = 1)] - pub task_id: Uuid, - - /// File path to read - #[arg(index = 2)] - pub file_path: String, -} - -/// Arguments for branch command. -#[derive(Args, Debug)] -pub struct BranchArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Branch name to create - #[arg(index = 1)] - pub name: String, - - /// Reference (task ID or SHA) to branch from - #[arg(long)] - pub from: Option<String>, -} - -/// Arguments for merge command. -#[derive(Args, Debug)] -pub struct MergeArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to merge - #[arg(index = 1)] - pub task_id: Uuid, - - /// Target branch to merge into - #[arg(long)] - pub to: Option<String>, - - /// Squash commits on merge - #[arg(long)] - pub squash: bool, -} - -/// Arguments for pr command. -#[derive(Args, Debug)] -pub struct PrArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Branch name to create PR from (e.g., "makima/feature-name") - #[arg(index = 1)] - pub branch: String, - - /// PR title - #[arg(long)] - pub title: String, - - /// PR body/description - #[arg(long)] - pub body: Option<String>, -} - -/// Arguments for diff command. -#[derive(Args, Debug)] -pub struct DiffArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to get diff for - #[arg(index = 1)] - pub task_id: Uuid, -} - -/// Arguments for checkpoint command. -#[derive(Args, Debug)] -pub struct CheckpointArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Checkpoint message - #[arg(index = 1)] - pub message: String, -} - -/// Arguments for ask command (ask user a question). -#[derive(Args, Debug)] -pub struct AskArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// The question to ask - #[arg(index = 1)] - pub question: String, - - /// Optional choices (comma-separated) - #[arg(long)] - pub choices: Option<String>, - - /// Context about what this relates to - #[arg(long)] - pub context: Option<String>, - - /// Timeout in seconds (default: 3600 = 1 hour) - #[arg(long, default_value = "3600")] - pub timeout: i32, - - /// Block indefinitely until user responds (no timeout) - #[arg(long, default_value = "false")] - pub phaseguard: bool, - - /// Allow selecting multiple choices (response will be comma-separated) - #[arg(long, default_value = "false")] - pub multi_select: bool, - - /// Non-blocking mode - returns immediately without waiting for response - #[arg(long, default_value = "false")] - pub non_blocking: bool, - - /// Question type (general, phase_confirmation, contract_complete) - #[arg(long, default_value = "general")] - pub question_type: String, -} - -/// Arguments for status command (get contract status including phase). -#[derive(Args, Debug)] -pub struct StatusArgs { - #[command(flatten)] - pub common: SupervisorArgs, -} - -/// Arguments for advance-phase command. -#[derive(Args, Debug)] -pub struct AdvancePhaseArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// The phase to advance to (specify, plan, execute, review) - #[arg(index = 1)] - pub phase: String, - - /// Confirm the phase transition (required when phase_guard is enabled). - /// Without this flag, the command will return deliverables for review. - #[arg(long, short = 'y')] - pub confirmed: bool, -} - -/// Arguments for mark-deliverable command. -#[derive(Args, Debug)] -pub struct MarkDeliverableArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request', 'research-notes') - #[arg(index = 1)] - pub deliverable_id: String, - - /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. - #[arg(long)] - pub phase: Option<String>, -} - -/// Arguments for task command (get individual task details). -#[derive(Args, Debug)] -pub struct GetTaskArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to get details for - #[arg(index = 1, id = "target_task_id")] - pub target_task_id: Uuid, -} - -/// Arguments for output command (get task output/claude log). -#[derive(Args, Debug)] -pub struct GetTaskOutputArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to get output for - #[arg(index = 1, id = "target_task_id")] - pub target_task_id: Uuid, -} - -// ============================================================================ -// History Command Args -// ============================================================================ - -/// Arguments for task-history command. -#[derive(Args, Debug)] -pub struct TaskHistoryArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to view history for - #[arg(index = 1)] - pub task_id: Uuid, - - /// Include tool calls in output - #[arg(long, default_value = "true")] - pub tool_calls: bool, - - /// Maximum messages to return - #[arg(long)] - pub limit: Option<i32>, - - /// Output format (table, json, chat) - #[arg(long, default_value = "chat")] - pub format: String, -} - -/// Arguments for task-checkpoints command (with optional diff). -#[derive(Args, Debug)] -pub struct TaskCheckpointsArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to list checkpoints for - #[arg(index = 1)] - pub task_id: Uuid, - - /// Include diff summary - #[arg(long)] - pub with_diff: bool, -} - -// ============================================================================ -// Resume Command Args -// ============================================================================ - -/// Arguments for resume command. -#[derive(Args, Debug)] -pub struct ResumeArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Resume mode: continue, restart_phase, from_checkpoint - #[arg(long, default_value = "continue")] - pub mode: String, - - /// Checkpoint ID (required for from_checkpoint mode) - #[arg(long)] - pub checkpoint: Option<Uuid>, - - /// Additional context to inject - #[arg(long)] - pub context: Option<String>, -} - -/// Arguments for task-resume-from command. -#[derive(Args, Debug)] -pub struct TaskResumeFromArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Source task ID - #[arg(index = 1)] - pub task_id: Uuid, - - /// Checkpoint number to resume from - #[arg(long)] - pub checkpoint: i32, - - /// Plan for the new task - #[arg(long)] - pub plan: String, - - /// Name for the new task - #[arg(long)] - pub name: Option<String>, -} - -// ============================================================================ -// Rewind Command Args -// ============================================================================ - -/// Arguments for task-rewind command. -#[derive(Args, Debug)] -pub struct TaskRewindArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Task ID to rewind - #[arg(index = 1)] - pub task_id: Uuid, - - /// Checkpoint number to rewind to - #[arg(long)] - pub checkpoint: i32, - - /// Preserve mode: discard, create_branch, stash - #[arg(long, default_value = "create_branch")] - pub preserve: String, - - /// Branch name (for create_branch mode) - #[arg(long)] - pub branch_name: Option<String>, -} - -/// Arguments for task-fork command. -#[derive(Args, Debug)] -pub struct TaskForkArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Source task ID - #[arg(index = 1)] - pub task_id: Uuid, - - /// Checkpoint number to fork from - #[arg(long)] - pub checkpoint: i32, - - /// Name for the new task - #[arg(long)] - pub name: String, - - /// Plan for the new task - #[arg(long)] - pub plan: String, - - /// Include conversation history - #[arg(long, default_value = "true")] - pub include_conversation: bool, -} - -/// Arguments for rewind-conversation command. -#[derive(Args, Debug)] -pub struct ConversationRewindArgs { - #[command(flatten)] - pub common: SupervisorArgs, - - /// Number of messages to rewind - #[arg(long)] - pub by_messages: Option<i32>, - - /// Message ID to rewind to - #[arg(long)] - pub to_message: Option<String>, - - /// Also rewind code to matching checkpoint - #[arg(long)] - pub rewind_code: bool, -} - -/// Arguments for complete command (mark contract as complete). -#[derive(Args, Debug)] -pub struct CompleteArgs { - #[command(flatten)] - pub common: SupervisorArgs, -} - -// ============================================================================ -// Resume Contract Command Args -// ============================================================================ - -/// Arguments for resume-contract command (reactivate a completed contract). -#[derive(Args, Debug)] -pub struct ResumeContractArgs { - /// API URL - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] - pub api_url: String, - - /// API key for authentication - #[arg(long, env = "MAKIMA_API_KEY")] - pub api_key: String, - - /// Contract ID to resume - #[arg(index = 1)] - pub contract_id: Uuid, -} - diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index 13f0862..e15608b 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -23,6 +23,6 @@ pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs}; +pub use cli::{Cli, Commands, ViewArgs}; pub use config::DaemonConfig; pub use error::{DaemonError, Result}; diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 1fe6e35..44af939 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2721,6 +2721,12 @@ pub struct Directive { pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, + /// True for the per-owner scratchpad directive. Auto-created on first + /// orphan-task creation. Hidden from the directive list; surfaced to + /// users via the sidebar's `tmp/` folder. Tasks attached to a tmp + /// directive are auto-deleted after 30 days. + #[serde(default)] + pub is_tmp: bool, } /// A historical record of a directive goal change. diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index b41c74c..f91bfaa 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -1189,6 +1189,86 @@ pub async fn list_tasks_for_owner( .await } +// ============================================================================= +// Tmp directive — per-owner scratchpad +// ============================================================================= + +/// Get the owner's tmp directive, creating it on the fly if absent. Idempotent +/// thanks to the partial unique index on (owner_id) WHERE is_tmp. +/// +/// We try an INSERT first with ON CONFLICT DO NOTHING; if a row was inserted +/// it's returned, otherwise we fall back to a SELECT for the row some other +/// request just created (or one that already existed). +pub async fn get_or_create_tmp_directive( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Directive, sqlx::Error> { + // Try insert first. RETURNING fires only if a row was actually written; + // if the partial unique index trips (a tmp directive already exists) + // we get None and fall through to the SELECT. + let inserted = sqlx::query_as::<_, Directive>( + r#" + INSERT INTO directives + (owner_id, title, goal, status, reconcile_mode, is_tmp) + VALUES + ($1, 'tmp', '', 'idle', 'auto', true) + ON CONFLICT DO NOTHING + RETURNING * + "#, + ) + .bind(owner_id) + .fetch_optional(pool) + .await?; + + if let Some(d) = inserted { + return Ok(d); + } + + // Pre-existing or just-created-by-someone-else: fetch. + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE owner_id = $1 AND is_tmp = true LIMIT 1"#, + ) + .bind(owner_id) + .fetch_one(pool) + .await +} + +/// Find every tmp directive (across owners). Used by the 30-day expiry +/// sweep — we need to know which directives are scratchpads so we know +/// which tasks to age out. +pub async fn list_all_tmp_directives( + pool: &PgPool, +) -> Result<Vec<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#"SELECT * FROM directives WHERE is_tmp = true"#, + ) + .fetch_all(pool) + .await +} + +/// Delete tasks attached to a tmp directive that are older than 30 days. +/// Returns the number of rows deleted (informational; we log it). +/// +/// We only sweep top-level tasks (parent_task_id IS NULL) — subtasks die +/// when their parent dies via the FK cascade. +pub async fn delete_expired_tmp_tasks( + pool: &PgPool, + tmp_directive_id: Uuid, +) -> Result<u64, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM tasks + WHERE directive_id = $1 + AND parent_task_id IS NULL + AND created_at < NOW() - INTERVAL '30 days' + "#, + ) + .bind(tmp_directive_id) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + /// List ephemeral tasks attached to a directive — tasks with `directive_id` /// set but no `directive_step_id`. These are the "spinoff" tasks the user /// created via the directive folder context menu, distinct from @@ -1223,14 +1303,15 @@ pub async fn list_ephemeral_directive_tasks_for_owner( .await } -/// List "orphan" top-level tasks for an owner — tasks that are NOT attached -/// to a directive and NOT a subtask of another task. These surface in the -/// document-mode sidebar under a top-level `tmp/` folder. Hidden tasks -/// excluded. -pub async fn list_orphan_tasks_for_owner( +/// List top-level tasks attached to the owner's tmp directive. These are +/// the scratchpad / orphan tasks surfaced under the sidebar's `tmp/` +/// folder. Auto-creates the tmp directive if it doesn't exist yet so the +/// caller never has to handle "no tmp directive". +pub async fn list_tmp_tasks_for_owner( pool: &PgPool, owner_id: Uuid, ) -> Result<Vec<TaskSummary>, sqlx::Error> { + let tmp = get_or_create_tmp_directive(pool, owner_id).await?; sqlx::query_as::<_, TaskSummary>( r#" SELECT @@ -1243,13 +1324,14 @@ pub async fn list_orphan_tasks_for_owner( FROM tasks t LEFT JOIN contracts c ON t.contract_id = c.id WHERE t.owner_id = $1 + AND t.directive_id = $2 AND t.parent_task_id IS NULL - AND t.directive_id IS NULL AND COALESCE(t.hidden, false) = false ORDER BY t.priority DESC, t.created_at DESC "#, ) .bind(owner_id) + .bind(tmp.id) .fetch_all(pool) .await } @@ -5066,7 +5148,9 @@ pub async fn get_directive_with_steps_for_owner( } } -/// List all directives for an owner with step counts. +/// List all directives for an owner with step counts. Excludes the per-owner +/// tmp directive (the scratchpad surface; surfaced via the sidebar's +/// dedicated `tmp/` folder, not the regular directive list). pub async fn list_directives_for_owner( pool: &PgPool, owner_id: Uuid, @@ -5093,6 +5177,7 @@ pub async fn list_directives_for_owner( WHERE directive_id = d.id ) s ON true WHERE d.owner_id = $1 + AND d.is_tmp = false ORDER BY d.created_at DESC "#, ) diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 1e004bf..80d8172 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -18,11 +18,22 @@ use crate::server::state::{DaemonCommand, SharedState}; pub struct DirectiveOrchestrator { pool: PgPool, state: SharedState, + /// Last time we ran the tmp-task expiry sweep. Throttled to once an + /// hour so the deletion query doesn't run on every 15-second tick. + last_tmp_sweep: std::time::Instant, } impl DirectiveOrchestrator { pub fn new(pool: PgPool, state: SharedState) -> Self { - Self { pool, state } + Self { + pool, + state, + // Initialise to 1 hour ago so the first tick after startup runs + // the sweep immediately — clears any tasks that aged out while + // the server was down. + last_tmp_sweep: std::time::Instant::now() + - std::time::Duration::from_secs(3600), + } } /// Run one orchestration tick — called every 15s. @@ -42,6 +53,14 @@ impl DirectiveOrchestrator { if let Err(e) = self.phase_completion().await { tracing::warn!(error = %e, "Directive phase_completion failed"); } + // Throttled to hourly — the actual delete is cheap (indexed + // partial scan) but we don't want to log a sweep every 15s. + if self.last_tmp_sweep.elapsed() >= std::time::Duration::from_secs(3600) { + self.last_tmp_sweep = std::time::Instant::now(); + if let Err(e) = self.phase_tmp_expiry().await { + tracing::warn!(error = %e, "Directive phase_tmp_expiry failed"); + } + } Ok(()) } @@ -100,40 +119,18 @@ impl DirectiveOrchestrator { let steps = repository::get_ready_steps_for_dispatch(&self.pool).await?; for step in steps { - // If the step has a contract_type, create a contract instead of a standalone task + // contract_type used to spawn a heavyweight contract+supervisor + // for a step. The contracts subsystem has been removed (Phase 5); + // we now treat any contract-backed step as a plain standalone + // task. The column itself is left in place for one more release + // so old data still reads cleanly, but it has no effect. if step.contract_type.is_some() { - tracing::info!( + tracing::warn!( step_id = %step.step_id, directive_id = %step.directive_id, - step_name = %step.step_name, contract_type = ?step.contract_type, - "Spawning contract for contract-backed step" + "Step has legacy contract_type; falling back to standalone task spawn" ); - - match self - .spawn_step_contract( - step.step_id, - step.directive_id, - step.owner_id, - &step.step_name, - step.step_description.as_deref(), - step.task_plan.as_deref(), - step.contract_type.as_deref().unwrap_or("simple"), - step.repository_url.as_deref(), - step.base_branch.as_deref(), - ) - .await - { - Ok(()) => {} - Err(e) => { - tracing::warn!( - step_id = %step.step_id, - error = %e, - "Failed to spawn contract for step" - ); - } - } - continue; } tracing::info!( @@ -647,141 +644,9 @@ impl DirectiveOrchestrator { Ok(()) } - /// Spawn a contract for a contract-backed step. - /// Creates a contract, adds the directive's repository to it, links it to the step, - /// creates a supervisor task, and marks the step as running. - async fn spawn_step_contract( - &self, - step_id: Uuid, - directive_id: Uuid, - owner_id: Uuid, - step_name: &str, - step_description: Option<&str>, - task_plan: Option<&str>, - contract_type: &str, - repo_url: Option<&str>, - base_branch: Option<&str>, - ) -> Result<(), anyhow::Error> { - // Build contract description from step info - let description = match (step_description, task_plan) { - (Some(desc), Some(plan)) => Some(format!("{}\n\n{}", desc, plan)), - (Some(desc), None) => Some(desc.to_string()), - (None, Some(plan)) => Some(plan.to_string()), - (None, None) => None, - }; - - // Create the contract - let contract_req = CreateContractRequest { - name: step_name.to_string(), - description, - contract_type: Some(contract_type.to_string()), - template_id: None, - initial_phase: None, - autonomous_loop: Some(true), - phase_guard: None, - local_only: None, - auto_merge_local: None, - }; - - let contract = repository::create_contract_for_owner(&self.pool, owner_id, contract_req).await?; - - tracing::info!( - step_id = %step_id, - contract_id = %contract.id, - contract_type = %contract.contract_type, - "Created contract for directive step" - ); - - // Link the contract to the step - repository::link_contract_to_step(&self.pool, step_id, contract.id).await?; - - // Add the directive's repository to the contract (if available) - if let Some(url) = repo_url { - if let Err(e) = repository::add_remote_repository( - &self.pool, - contract.id, - step_name, - url, - true, // is_primary - ) - .await - { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to add repository to contract — continuing without it" - ); - } - } - - // Create supervisor task for the contract (following the pattern from contract handlers) - let supervisor_name = format!("{} Supervisor", step_name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - step_name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - let supervisor_req = CreateTaskRequest { - name: supervisor_name.clone(), - description: None, - plan: supervisor_plan.clone(), - repository_url: repo_url.map(|s| s.to_string()), - base_branch: base_branch.map(|s| s.to_string()), - target_branch: None, - parent_task_id: None, - contract_id: Some(contract.id), - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, - directive_id: Some(directive_id), - directive_step_id: Some(step_id), - }; - - let supervisor_task = repository::create_task_for_owner(&self.pool, owner_id, supervisor_req).await?; - - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - "Created supervisor task for contract-backed step" - ); - - // Link supervisor task to contract - let update_req = UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(&self.pool, contract.id, owner_id, update_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to link supervisor task to contract" - ); - } - - // Try to dispatch the supervisor task to a daemon - if self - .try_dispatch_task(supervisor_task.id, owner_id, &supervisor_task.name, &supervisor_task.plan, supervisor_task.version) - .await - { - repository::set_step_running(&self.pool, step_id).await?; - } else { - // Even if dispatch fails, mark step as running since contract is created. - // The supervisor task will be retried by the pending task retry logic. - repository::set_step_running(&self.pool, step_id).await?; - } - - Ok(()) - } + // spawn_step_contract was removed in Phase 5 — the contracts subsystem + // is gone. Step rows with `contract_type` set are now silently treated + // as standalone tasks (see the warn! in phase_execution). /// Try to dispatch a task to an available daemon. Returns true if dispatched. async fn try_dispatch_task( @@ -877,6 +742,40 @@ impl DirectiveOrchestrator { false } + /// Hourly sweep — delete top-level tasks attached to any tmp directive + /// that are older than 30 days. Per-owner; no global cap. Subtasks die + /// via the FK cascade. + async fn phase_tmp_expiry(&self) -> Result<(), anyhow::Error> { + let tmps = repository::list_all_tmp_directives(&self.pool).await?; + let mut total_deleted: u64 = 0; + for d in tmps { + match repository::delete_expired_tmp_tasks(&self.pool, d.id).await { + Ok(n) => { + if n > 0 { + tracing::info!( + directive_id = %d.id, + owner_id = %d.owner_id, + deleted = n, + "Expired tmp tasks deleted (>30 days old)" + ); + total_deleted += n; + } + } + Err(e) => { + tracing::warn!( + directive_id = %d.id, + error = %e, + "Failed to expire tmp tasks for owner" + ); + } + } + } + if total_deleted > 0 { + tracing::info!(total = total_deleted, "Tmp expiry sweep completed"); + } + Ok(()) + } + /// Phase 5: Completion — spawn PR-creation tasks for idle directives. async fn phase_completion(&self) -> Result<(), anyhow::Error> { // Part 1: Spawn completion tasks for idle directives diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs deleted file mode 100644 index 5d8ab3e..0000000 --- a/makima/src/server/handlers/contract_chat.rs +++ /dev/null @@ -1,3183 +0,0 @@ -//! Chat endpoint for LLM-powered contract management. -//! -//! This handler provides an agentic loop for managing contracts: creating tasks, -//! adding files, managing repositories, and handling phase transitions. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{ - models::{ - ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest, - }, - repository, -}; -use crate::llm::{ - analyze_task_output, body_to_markdown, format_checklist_markdown, - format_parsed_tasks, parse_tasks_from_breakdown, - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - parse_contract_tool_call, ContractToolRequest, - LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, - format_transcript_for_analysis, calculate_speaker_stats, - build_analysis_prompt, parse_analysis_response, -}; -use crate::server::auth::Authenticated; -use crate::server::state::{DaemonCommand, SharedState}; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 30; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatHistoryMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatRequest { - /// The user's message/instruction - pub message: String, - /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" - #[serde(default)] - pub model: Option<String>, - /// Optional conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ContractChatHistoryMessage>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatResponse { - /// The LLM's response message - pub response: String, - /// Tool calls that were executed - pub tool_calls: Vec<ContractToolCallInfo>, - /// Questions pending user answers (pauses conversation) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Helper to get contract with all relations -async fn get_contract_with_relations( - pool: &sqlx::PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Option<ContractWithRelations>, sqlx::Error> { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? { - Some(c) => c, - None => return Ok(None), - }; - - let repositories = repository::list_contract_repositories(pool, contract_id) - .await - .unwrap_or_default(); - - let files = repository::list_files_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - Ok(Some(ContractWithRelations { - contract, - repositories, - files, - tasks, - })) -} - -/// Chat with a contract using LLM tool calling for management -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/chat", - request_body = ContractChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = ContractChatResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn contract_chat_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, - Json(request): Json<ContractChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Get the contract (scoped by owner) - let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract chat using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build contract context - let contract_context = build_contract_context(&contract); - - // Build system prompt for contract management - let system_prompt = format!( - r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks. - -## Your Capabilities -You have access to tools for: -- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file -- **File Management**: create_file_from_template, create_empty_file, list_available_templates -- **Task Management**: create_contract_task, delegate_content_generation, start_task -- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase -- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository -- **Interactive**: ask_user - -## Content Generation Deferral -When asked to write substantial content, fill templates, or generate documentation: -- **Use delegate_content_generation** to create a task for the content generation -- This delegates the work to a task agent that can do more thorough research and writing - -**Use delegation for:** -- Filling in template content with real data -- Writing documentation based on requirements -- Generating user stories or specifications -- Creating detailed design documents -- Any substantial writing that requires research or analysis - -**Direct actions (no delegation needed):** -- Listing files/tasks/repos -- Reading files -- Phase transitions -- Creating empty files or templates -- Simple queries and status checks -- Asking user questions - -## Contract Lifecycle Phases - -### 1. RESEARCH Phase -**Purpose**: Gather information and understand the problem space -**Key Activities**: -- Conduct user research and interviews -- Analyze competitors and existing solutions -- Document findings and insights -- Identify opportunities and constraints -**Suggested Actions**: -- Create a "Research Notes" document to capture findings -- Create a "Competitor Analysis" document -- When research is complete, suggest transitioning to Specify phase - -### 2. SPECIFY Phase -**Purpose**: Define what needs to be built -**Key Activities**: -- Write clear requirements -- Create user stories with acceptance criteria -- Define scope and constraints -- Document technical constraints -**Suggested Actions**: -- Create a "Requirements" document -- Create "User Stories" with acceptance criteria -- When specifications are clear, suggest transitioning to Plan phase - -### 3. PLAN Phase -**Purpose**: Design the solution and break down the work -**Key Activities**: -- Design system architecture -- Create technical specifications -- Break work into implementable tasks -- Set up repositories for development -**Suggested Actions**: -- Create an "Architecture" document -- Create a "Task Breakdown" document -- **IMPORTANT**: Help set up a repository if not already configured -- When planning is complete and a repository is set, suggest transitioning to Execute phase - -### 4. EXECUTE Phase -**Purpose**: Implement the solution -**Key Activities**: -- Create and run tasks to implement features -- Write and run tests -- Track progress -- Document implementation decisions -**Suggested Actions**: -- Create tasks based on the task breakdown -- Monitor task progress and help resolve blockers -- When all tasks are complete, suggest transitioning to Review phase - -### 5. REVIEW Phase -**Purpose**: Validate and document the completed work -**Key Activities**: -- Review completed work -- Create release notes -- Conduct retrospective -- Document learnings -**Suggested Actions**: -- Create a "Release Notes" document -- Create a "Retrospective" document -- Help mark the contract as complete when review is done - -## Current Contract -{contract_context} - -## Proactive Guidance - -### Repository Setup (Critical for Plan/Execute phases) -When the user wants to add a local repository or set up for execution: -1. **First call list_daemon_directories** to get available paths from connected agents -2. Present the suggested directories to the user -3. Ask which path they want to use, or let them specify a custom path -4. Then call add_repository with the chosen path - -Example flow: -``` -User: "Set up a repository for this contract" -You: Call list_daemon_directories first -You: "I found these directories from your connected agent: - - /Users/alice/projects (Working Directory) - - /Users/alice/.makima/home (Makima Home) - Which would you like to use, or provide a custom path?" -``` - -### Phase Transitions -- Phases progress in order: research -> specify -> plan -> execute -> review -- You can ONLY advance forward one step at a time to the NEXT phase -- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value -- Then use advance_phase with that exact nextPhase value -- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan" -- NEVER suggest advancing to the same phase the contract is already in - -### New Users -When a new contract is created or the user seems unsure: -1. Explain the current phase and what should be done -2. Suggest creating appropriate documents -3. Guide them toward the next milestone - -## Agentic Behavior Guidelines - -### 1. Understand Before Acting -- For complex requests, first gather information about the contract's current state -- Use get_contract_status or list_contract_files to understand what exists -- Consider the current phase when suggesting actions - -### 2. Phase-Appropriate Suggestions -- Suggest templates and actions appropriate for the current phase -- When creating files, prefer templates that match the contract's phase -- Advise when the contract might be ready for the next phase - -### 3. Help Plan Work -- When asked to plan work, read existing files to understand context -- Suggest creating tasks based on requirements or plans in files -- Offer to create task breakdowns from design documents - -### 4. Repository Management -- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions -- This provides the user with valid paths from their connected agents -- Don't ask users to manually type paths when suggestions are available - -### 5. Task Creation and Execution -- When creating tasks, derive plans from existing contract files when possible -- Use the contract's primary repository for tasks by default -- Create clear, actionable task plans -- After creating a task, you can use **start_task** to immediately begin execution -- A daemon must be connected for start_task to work - -### 6. Be Proactive but Efficient -- Guide users through the contract flow -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Provide clear summaries of actions taken - -## Important Notes -- This contract's ID is: {contract_id} -- All operations are scoped to this contract -- When creating tasks or files, they are automatically associated with this contract"#, - contract_context = contract_context, - contract_id = contract_id - ); - - // Run the agentic loop - run_contract_agentic_loop( - pool, - &state, - &llm_client, - system_prompt, - &request, - contract_id, - auth.owner_id, - ) - .await -} - -fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String { - let c = &contract.contract; - let mut context = format!( - "Name: {}\nID: {}\nPhase: {}\nStatus: {}\nContract Type: {}\nAutonomous Loop: {}\n", - c.name, c.id, c.phase, c.status, c.contract_type, c.autonomous_loop - ); - - if let Some(ref desc) = c.description { - context.push_str(&format!("Description: {}\n", desc)); - } - - // Get completed deliverables for the current phase - let completed_deliverables = c.get_completed_deliverables(&c.phase); - - // Build task infos for checklist - let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !contract.repositories.is_empty(); - let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &completed_deliverables, &task_infos, has_repository, &c.contract_type); - - // Add phase checklist to context - context.push_str("\n"); - context.push_str(&format_checklist_markdown(&phase_checklist)); - - // Add deliverable check result for phase transition readiness - let deliverable_check = crate::llm::check_deliverables_met( - &c.phase, - &c.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Add deliverable prompt guidance - context.push_str(&crate::llm::generate_deliverable_prompt_guidance( - &c.phase, - &c.contract_type, - &deliverable_check, - )); - - // Files summary - context.push_str(&format!("\n### Files ({} total)\n", contract.files.len())); - if !contract.files.is_empty() { - for file in contract.files.iter().take(5) { - let phase_label = file.contract_phase.as_deref().unwrap_or("none"); - context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id)); - } - if contract.files.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.files.len() - 5)); - } - } - - // Tasks summary - context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len())); - if !contract.tasks.is_empty() { - let pending = contract.tasks.iter().filter(|t| t.status == "pending").count(); - let running = contract.tasks.iter().filter(|t| t.status == "running").count(); - let done = contract.tasks.iter().filter(|t| t.status == "done").count(); - context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done)); - for task in contract.tasks.iter().take(5) { - context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id)); - } - if contract.tasks.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5)); - } - } - - // Repositories summary - context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len())); - if !contract.repositories.is_empty() { - for repo in &contract.repositories { - let primary = if repo.is_primary { " (primary)" } else { "" }; - let url_or_path = repo.repository_url.as_deref() - .or(repo.local_path.as_deref()) - .unwrap_or("managed"); - context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary)); - } - } - - context -} - -/// Summarize older conversation history to reduce token usage -async fn summarize_conversation_history( - llm_client: &LlmClient, - messages: &[&crate::db::models::ContractChatMessageRecord], -) -> String { - // Build conversation text for summarization - let mut conversation_text = String::new(); - for msg in messages { - let role_label = if msg.role == "user" { "User" } else { "Assistant" }; - // Limit each message to avoid overwhelming the summarizer - let content = if msg.content.len() > 500 { - format!("{}...", &msg.content[..500]) - } else { - msg.content.clone() - }; - conversation_text.push_str(&format!("{}: {}\n", role_label, content)); - } - - // Limit total text to summarize - if conversation_text.len() > 8000 { - conversation_text = format!("{}...", &conversation_text[..8000]); - } - - let summary_prompt = format!( - "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}", - conversation_text - ); - - // Use a simple chat call without tools for summarization - let summary = match llm_client { - LlmClient::Claude(client) => { - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(summary_prompt.clone()), - }]; - match client.chat_with_tools(claude_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - LlmClient::Groq(client) => { - let groq_messages = vec![Message { - role: "user".to_string(), - content: Some(summary_prompt.clone()), - tool_calls: None, - tool_call_id: None, - }]; - match client.chat_with_tools(groq_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - }; - - // Limit summary length - if summary.len() > 500 { - format!("{}...", &summary[..500]) - } else { - summary - } -} - -/// Run the agentic loop for contract chat -async fn run_contract_agentic_loop( - pool: &sqlx::PgPool, - state: &SharedState, - llm_client: &LlmClient, - system_prompt: String, - request: &ContractChatRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> axum::response::Response { - // Get or create the conversation for persistent history - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get/create contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Load ALL existing messages from database - let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::warn!("Failed to load contract chat history: {}", e); - Vec::new() - } - }; - - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add saved conversation history, summarizing older messages if needed - // to stay under rate limits (~25k chars ≈ ~6k tokens for history) - const MAX_HISTORY_CHARS: usize = 25000; - const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact - - // Filter to user/assistant messages only - let history_messages: Vec<_> = saved_messages - .iter() - .filter(|m| m.role == "user" || m.role == "assistant") - .collect(); - - // Calculate total character count - let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum(); - - if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP { - // Need to summarize older messages - let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP); - let older_messages = &history_messages[..split_point]; - let recent_messages = &history_messages[split_point..]; - - // Generate summary of older conversation - let summary = summarize_conversation_history(&llm_client, older_messages).await; - - // Add summary as context - messages.push(Message { - role: "user".to_string(), - content: Some(format!("[Previous conversation summary: {}]", summary)), - tool_calls: None, - tool_call_id: None, - }); - messages.push(Message { - role: "assistant".to_string(), - content: Some("I understand the previous context. Let's continue.".to_string()), - tool_calls: None, - tool_call_id: None, - }); - - // Add recent messages in full - for saved_msg in recent_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - - tracing::info!( - total_messages = history_messages.len(), - summarized = older_messages.len(), - kept_recent = recent_messages.len(), - "Summarized older conversation history" - ); - } else { - // Add all messages directly - for saved_msg in history_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // Save the user message to database - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "user", - &request.message, - None, - None, - ).await { - tracing::warn!("Failed to save user message to contract chat history: {}", e); - } - - // State for tracking - let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: usize = 3; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract agentic loop iteration" - ); - - // Check consecutive failures - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - tracing::warn!( - "Breaking contract loop due to {} consecutive failures", - consecutive_failures - ); - final_response = Some( - "I encountered multiple consecutive errors and stopped. \ - Please check the contract state and try again." - .to_string(), - ); - break; - } - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &CONTRACT_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call"); - - // Parse the tool call - let mut execution_result = parse_contract_tool_call(tool_call); - - // Handle async contract tool requests - if let Some(contract_request) = execution_result.request.take() { - let async_result = - handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - } - - // Track consecutive failures - if execution_result.success { - consecutive_failures = 0; - } else { - consecutive_failures += 1; - tracing::warn!( - tool = %tool_call.name, - consecutive_failures = consecutive_failures, - "Contract tool call failed" - ); - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Contract LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - format!( - "Done! Executed {} tool{}.", - all_tool_call_infos.len(), - if all_tool_call_infos.len() == 1 { "" } else { "s" } - ) - } - }); - - // Save assistant response to database - let tool_calls_json = if all_tool_call_infos.is_empty() { - None - } else { - serde_json::to_value(&all_tool_call_infos).ok() - }; - - let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok()); - - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "assistant", - &response_text, - tool_calls_json, - pending_questions_json, - ).await { - tracing::warn!("Failed to save assistant response to contract chat history: {}", e); - } - - ( - StatusCode::OK, - Json(ContractChatResponse { - response: response_text, - tool_calls: all_tool_call_infos, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async contract tool request -struct ContractRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async contract tool requests that require database access -async fn handle_contract_request( - pool: &sqlx::PgPool, - daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>, - request: ContractToolRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> ContractRequestResult { - match request { - ContractToolRequest::ListDaemonDirectories => { - let mut directories = Vec::new(); - - // Iterate over connected daemons belonging to this owner - for entry in daemon_connections.iter() { - let daemon = entry.value(); - - // Only include daemons belonging to this owner - if daemon.owner_id != owner_id { - continue; - } - - // Add working directory if available - if let Some(ref working_dir) = daemon.working_directory { - directories.push(json!({ - "path": working_dir, - "label": "Working Directory", - "type": "working", - "hostname": daemon.hostname, - })); - } - - // Add home directory if available - if let Some(ref home_dir) = daemon.home_directory { - directories.push(json!({ - "path": home_dir, - "label": "Makima Home", - "type": "home", - "hostname": daemon.hostname, - })); - } - } - - if directories.is_empty() { - ContractRequestResult { - success: true, - message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(), - data: Some(json!({ "directories": [] })), - } - } else { - ContractRequestResult { - success: true, - message: format!("Found {} suggested directories from connected daemons", directories.len()), - data: Some(json!({ "directories": directories })), - } - } - } - - ContractToolRequest::GetContractStatus => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let c = &cwr.contract; - ContractRequestResult { - success: true, - message: format!( - "Contract '{}' is in '{}' phase with status '{}'", - c.name, c.phase, c.status - ), - data: Some(json!({ - "name": c.name, - "phase": c.phase, - "status": c.status, - "description": c.description, - "fileCount": cwr.files.len(), - "taskCount": cwr.tasks.len(), - "repositoryCount": cwr.repositories.len(), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractFiles => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let files: Vec<serde_json::Value> = cwr - .files - .iter() - .map(|f| { - json!({ - "fileId": f.id, - "name": f.name, - "description": f.description, - "phase": f.contract_phase, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} files", files.len()), - data: Some(json!({ "files": files })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractTasks => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let tasks: Vec<serde_json::Value> = cwr - .tasks - .iter() - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks", tasks.len()), - data: Some(json!({ "tasks": tasks })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractRepositories => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let repos: Vec<serde_json::Value> = cwr - .repositories - .iter() - .map(|r| { - json!({ - "repositoryId": r.id, - "name": r.name, - "repositoryUrl": r.repository_url, - "localPath": r.local_path, - "isPrimary": r.is_primary, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} repositories", repos.len()), - data: Some(json!({ "repositories": repos })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ReadFile { file_id } => { - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for LLM consumption - let markdown = body_to_markdown(&file.body); - - ContractRequestResult { - success: true, - message: format!("Read file '{}'", file.name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - "description": file.description, - "summary": file.summary, - "plainText": markdown, - "phase": file.contract_phase, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateEmptyFile { name, description } => { - // Verify contract exists and get current phase - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Create the file with current contract phase - let create_req = crate::db::models::CreateFileRequest { - contract_id, - name: Some(name.clone()), - description, - body: Vec::new(), - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some(contract.phase.clone()), - }; - - match repository::create_file_for_owner(pool, owner_id, create_req).await { - Ok(file) => ContractRequestResult { - success: true, - message: format!("Created empty file '{}'", name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create file: {}", e), - data: None, - }, - } - } - - ContractToolRequest::MarkDeliverableComplete { - deliverable_id, - phase, - } => { - // Get the contract to determine current phase and contract type - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Use specified phase or default to current contract phase - let target_phase = phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type); - let deliverable_exists = phase_deliverables.deliverables.iter().any(|d| d.id == deliverable_id); - - if !deliverable_exists { - let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect(); - return ContractRequestResult { - success: false, - message: format!( - "Invalid deliverable_id '{}' for {} phase. Valid IDs: {:?}", - deliverable_id, target_phase, valid_ids - ), - data: None, - }; - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &deliverable_id) { - return ContractRequestResult { - success: true, - message: format!("Deliverable '{}' is already marked complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })), - }; - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, contract_id, &target_phase, &deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - ContractRequestResult { - success: true, - message: format!("Marked deliverable '{}' as complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to mark deliverable complete: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateContractTask { - name, - plan, - repository_url, - base_branch, - } => { - // Get primary repository if not specified - let repo_url = if repository_url.is_some() { - repository_url - } else { - // Find primary repository - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - } - }; - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: name.clone(), - description: None, - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!("Created task '{}' in contract", name), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::DelegateContentGeneration { - file_id, - instruction, - context, - } => { - // Build a task plan that includes the content generation instruction - let mut plan = format!( - "Content Generation Task\n\n\ - ## Instruction\n{}\n\n", - instruction - ); - - if let Some(ctx) = context { - plan.push_str(&format!("## Context\n{}\n\n", ctx)); - } - - // If file_id is provided, get file details and include them - let (file_name, file_info) = if let Some(fid) = file_id { - match repository::get_file_for_owner(pool, fid, owner_id).await { - Ok(Some(file)) => { - let info = format!( - "## Target File\n\ - - File ID: {}\n\ - - Name: {}\n\ - - Description: {}\n\n\ - The generated content should be structured to update this file.\n", - fid, - file.name, - file.description.as_deref().unwrap_or("(no description)") - ); - (Some(file.name.clone()), Some(info)) - } - _ => (None, None), - } - } else { - (None, None) - }; - - if let Some(info) = file_info { - plan.push_str(&info); - } - - // Get primary repository - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())), - _ => None, - }; - - let task_name = format!( - "Generate content{}", - file_name.map(|n| format!(": {}", n)).unwrap_or_default() - ); - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_name.clone(), - description: Some(instruction.clone()), - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!( - "Created content generation task '{}'. Start the task to generate the content.", - task_name - ), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "targetFileId": file_id, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create content generation task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::StartTask { task_id } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to get task: {}", e), - data: None, - } - } - }; - - // Check if task can be started - let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"]; - if !startable_statuses.contains(&task.status.as_str()) { - return ContractRequestResult { - success: false, - message: format!("Task cannot be started from status: {}", task.status), - data: None, - }; - } - - // Find a connected daemon for this owner - let daemon_entry = daemon_connections - .iter() - .find(|d| d.value().owner_id == owner_id); - - let (target_daemon_id, command_sender) = match daemon_entry { - Some(entry) => { - let daemon = entry.value(); - (daemon.id, daemon.command_sender.clone()) - } - None => { - return ContractRequestResult { - success: false, - message: "No daemon connected. Start a daemon to run tasks.".to_string(), - data: None, - }; - } - }; - - // Check if this is an orchestrator - let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { - Ok(subtasks) => subtasks.len(), - Err(_) => 0, - }; - let is_orchestrator = task.depth == 0 && subtask_count > 0; - - // Update task status to 'starting' and assign daemon_id - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(target_daemon_id), - version: Some(task.version), - ..Default::default() - }; - - let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to update task: {}", e), - data: None, - }; - } - }; - - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; - - // Send SpawnTask command to daemon - let command = DaemonCommand::SpawnTask { - task_id, - task_name: task.name.clone(), - plan: task.plan.clone(), - repo_url: task.repository_url.clone(), - base_branch: task.base_branch.clone(), - target_branch: task.target_branch.clone(), - parent_task_id: task.parent_task_id, - depth: task.depth, - is_orchestrator, - target_repo_path: task.target_repo_path.clone(), - completion_action: task.completion_action.clone(), - continue_from_task_id: task.continue_from_task_id, - copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: task.contract_id, - is_supervisor: task.is_supervisor, - autonomous_loop: false, - resume_session: false, - conversation_history: None, - patch_data: None, - patch_base_sha: None, - local_only, - auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: task.directive_id, - }; - - if let Err(e) = command_sender.send(command).await { - // Rollback: reset status since command failed - let rollback_req = crate::db::models::UpdateTaskRequest { - status: Some("pending".to_string()), - clear_daemon_id: true, - ..Default::default() - }; - let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await; - return ContractRequestResult { - success: false, - message: format!("Failed to send task to daemon: {}", e), - data: None, - }; - } - - // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status - ContractRequestResult { - success: true, - message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name), - data: Some(json!({ - "taskId": task_id, - "name": task.name, - "status": "starting", - })), - } - } - - ContractToolRequest::GetPhaseInfo => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let phase_info = get_phase_description(&contract.phase); - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let deliverable_names: Vec<String> = phase_deliverables.deliverables.iter().map(|d| d.name.clone()).collect(); - - ContractRequestResult { - success: true, - message: format!("Contract is in '{}' phase", contract.phase), - data: Some(json!({ - "phase": contract.phase, - "description": phase_info.0, - "activities": phase_info.1, - "deliverables": deliverable_names, - "guidance": phase_deliverables.guidance, - "nextPhase": get_next_phase(&contract.phase), - })), - } - } - - ContractToolRequest::SuggestPhaseTransition => { - let contract = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let analysis = analyze_phase_readiness(&contract); - - ContractRequestResult { - success: true, - message: analysis.summary.clone(), - data: Some(json!({ - "currentPhase": contract.contract.phase, - "nextPhase": get_next_phase(&contract.contract.phase), - "ready": analysis.ready, - "summary": analysis.summary, - "reasons": analysis.reasons, - "suggestions": analysis.suggestions, - })), - } - } - - ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Validate phase transition - let current_phase = &contract.phase; - let valid_next = get_next_phase(current_phase); - - if valid_next.as_deref() != Some(&new_phase) { - return ContractRequestResult { - success: false, - message: format!( - "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}", - current_phase, new_phase, valid_next - ), - data: None, - }; - } - - // Check if deliverables are met before allowing transition - let cwr = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) | Err(_) => { - // Fall through - we'll just skip the deliverables check - return ContractRequestResult { - success: false, - message: "Failed to load contract for deliverables check".to_string(), - data: None, - }; - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = cwr.contract.get_completed_deliverables(current_phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - current_phase, - &contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Block transition if deliverables are not met - if !check_result.deliverables_met { - return ContractRequestResult { - success: false, - message: format!( - "Cannot advance to '{}' phase: deliverables not met. {}", - new_phase, check_result.summary - ), - data: Some(json!({ - "status": "deliverables_not_met", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "deliverablesMet": false, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "action": "Complete the missing deliverables before advancing to the next phase" - })), - }; - } - - // Check if phase_guard is enabled - if contract.phase_guard { - // If user provided feedback, return it for the task to address - if let Some(ref user_feedback) = feedback { - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires changes. User feedback: {}", - new_phase, user_feedback - ), - data: Some(json!({ - "status": "changes_requested", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "feedback": user_feedback, - "action": "Address the user feedback and try again when ready" - })), - }; - } - - // If not confirmed, return requires_confirmation with phase deliverables - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if !confirmed { - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(current_phase)) - .map(|f| json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(current_phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(current_phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - // Build deliverables summary - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - current_phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.", - new_phase - ), - data: Some(json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": current_phase, - "nextPhase": new_phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required.", - "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'" - })), - }; - } - } - - // Update phase (either phase_guard is disabled, or user confirmed) - match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { - Ok(Some(updated)) => { - // Get deliverables for the new phase (using contract type) - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type); - - // Build deliverables list - let deliverables_list: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "priority": format!("{:?}", d.priority).to_lowercase(), - "description": d.description, - })) - .collect(); - - ContractRequestResult { - success: true, - message: format!( - "Advanced contract from '{}' to '{}' phase. {}", - current_phase, new_phase, phase_deliverables.guidance - ), - data: Some(json!({ - "status": "advanced", - "previousPhase": current_phase, - "newPhase": updated.phase, - "phaseGuidance": phase_deliverables.guidance, - "deliverables": deliverables_list, - "requiresRepository": phase_deliverables.requires_repository, - "requiresTasks": phase_deliverables.requires_tasks, - })), - } - }, - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update phase".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to update phase: {}", e), - data: None, - }, - } - } - - ContractToolRequest::AddRepository { - repo_type, - name, - url, - is_primary, - } => { - let add_result = match repo_type.as_str() { - "remote" => { - let url = url.unwrap_or_default(); - repository::add_remote_repository( - pool, - contract_id, - &name, - &url, - is_primary, - ) - .await - } - "local" => { - let path = url.unwrap_or_default(); - repository::add_local_repository( - pool, - contract_id, - &name, - &path, - is_primary, - ) - .await - } - "managed" => { - repository::create_managed_repository(pool, contract_id, &name, is_primary) - .await - } - _ => { - return ContractRequestResult { - success: false, - message: format!("Invalid repository type: {}", repo_type), - data: None, - } - } - }; - - match add_result { - Ok(repo) => ContractRequestResult { - success: true, - message: format!("Added {} repository '{}'", repo_type, name), - data: Some(json!({ - "repositoryId": repo.id, - "name": repo.name, - "isPrimary": repo.is_primary, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to add repository: {}", e), - data: None, - }, - } - } - - ContractToolRequest::SetPrimaryRepository { repository_id } => { - match repository::set_repository_primary(pool, repository_id, contract_id).await { - Ok(true) => ContractRequestResult { - success: true, - message: "Set repository as primary".to_string(), - data: Some(json!({ - "repositoryId": repository_id, - })), - }, - Ok(false) => ContractRequestResult { - success: false, - message: "Repository not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to set primary repository: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Phase Guidance Handlers - // ============================================================================= - - ContractToolRequest::GetPhaseChecklist => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &completed_deliverables, &task_infos, has_repository, &cwr.contract.contract_type); - - ContractRequestResult { - success: true, - message: checklist.summary.clone(), - data: Some(json!({ - "phase": checklist.phase, - "completionPercentage": checklist.completion_percentage, - "deliverables": checklist.deliverables, - "hasRepository": checklist.has_repository, - "repositoryRequired": checklist.repository_required, - "taskStats": checklist.task_stats, - "suggestions": checklist.suggestions, - "summary": checklist.summary, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CheckDeliverablesMet => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Check if we should auto-progress - let auto_progress = crate::llm::should_auto_progress( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - cwr.contract.autonomous_loop, - ); - - ContractRequestResult { - success: true, - message: check_result.summary.clone(), - data: Some(json!({ - "deliverablesMet": check_result.deliverables_met, - "readyToAdvance": check_result.ready_to_advance, - "phase": check_result.phase, - "nextPhase": check_result.next_phase, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "summary": check_result.summary, - "autoProgressRecommended": check_result.auto_progress_recommended, - "autoProgress": { - "shouldProgress": auto_progress.should_progress, - "nextPhase": auto_progress.next_phase, - "reason": auto_progress.reason, - "action": format!("{:?}", auto_progress.action), - }, - "autonomousLoop": cwr.contract.autonomous_loop, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Task Derivation Handlers - // ============================================================================= - - ContractToolRequest::DeriveTasksFromFile { file_id } => { - // First get the file - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for task parsing - let markdown = body_to_markdown(&file.body); - - // Parse tasks from the content - let parse_result = parse_tasks_from_breakdown(&markdown); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks in file '{}'", parse_result.total, file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": file.name, - "tasks": parse_result.tasks, - "groups": parse_result.groups, - "total": parse_result.total, - "warnings": parse_result.warnings, - "formatted": format_parsed_tasks(&parse_result), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateChainedTasks { tasks } => { - // Get primary repository for tasks - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - }; - - let mut created_tasks = Vec::new(); - let mut previous_task_id: Option<Uuid> = None; - - for task_def in &tasks { - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_def.name.clone(), - description: None, - plan: task_def.plan.clone(), - parent_task_id: None, - repository_url: repo_url.clone(), - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: previous_task_id, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => { - created_tasks.push(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "chainedFrom": previous_task_id, - })); - previous_task_id = Some(task.id); - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create task '{}': {}", task_def.name, e), - data: Some(json!({ - "createdSoFar": created_tasks, - })), - }; - } - } - } - - ContractRequestResult { - success: true, - message: format!("Created {} chained tasks", created_tasks.len()), - data: Some(json!({ - "tasks": created_tasks, - "total": created_tasks.len(), - })), - } - } - - // ============================================================================= - // Task Completion Processing Handlers - // ============================================================================= - - ContractToolRequest::ProcessTaskCompletion { task_id } => { - // Get the task - match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(task)) => { - // Verify task belongs to this contract - if task.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "Task does not belong to this contract".to_string(), - data: None, - }; - } - - // Get contract for context - let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten(); - - let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0); - let completed_tasks = contract.as_ref() - .map(|c| c.tasks.iter().filter(|t| t.status == "done").count()) - .unwrap_or(0); - - // Note: Finding next chained task would require querying full Task objects - // Since TaskSummary doesn't have continue_from_task_id, we skip this for now - let next_task: Option<(Uuid, String)> = None; - - // Find Dev Notes file if exists - let dev_notes = if let Some(ref c) = contract { - c.files.iter() - .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes")) - .map(|f| (f.id, f.name.clone())) - } else { - None - }; - - let contract_phase = contract.as_ref() - .map(|c| c.contract.phase.clone()) - .unwrap_or_else(|| "execute".to_string()); - - // Analyze the task output - let analysis = analyze_task_output( - task_id, - &task.name, - task.last_output.as_deref(), - task.progress_summary.as_deref(), - &contract_phase, - total_tasks, - completed_tasks, - next_task, - dev_notes, - ); - - ContractRequestResult { - success: true, - message: format!("Analyzed completion of task '{}'", task.name), - data: Some(json!({ - "taskId": task_id, - "taskName": task.name, - "taskStatus": task.status, - "summary": analysis.summary, - "filesAffected": analysis.files_affected, - "nextSteps": analysis.next_steps, - "phaseImpact": analysis.phase_impact, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Build the section to add - let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name)); - let result_text = task.last_output.as_deref().unwrap_or("Task completed"); - - // Create new body elements to append - let mut new_body = file.body.clone(); - new_body.push(crate::db::models::BodyElement::Heading { - level: 2, - text: title, - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: format!("Status: {}", task.status), - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: result_text.to_string(), - }); - - // Update the file using UpdateFileRequest - let update_req = UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(new_body), - version: None, // Don't require version for this update - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await { - Ok(Some(updated_file)) => { - ContractRequestResult { - success: true, - message: format!("Updated file '{}' with task summary", file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": updated_file.name, - "taskId": task_id, - "taskName": task.name, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update file".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Transcript Analysis Handlers - // ============================================================================= - - ContractToolRequest::AnalyzeTranscript { file_id } => { - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript to analyze".to_string(), - data: None, - }; - } - - // Format and analyze - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Call Claude for analysis - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(analysis) => { - ContractRequestResult { - success: true, - message: format!( - "Analysis complete: {} requirements, {} decisions, {} action items", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ), - data: Some(json!({ - "analysis": analysis - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - } - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - } - } - } - - ContractToolRequest::CreateContractFromTranscript { - file_id, name, description, include_requirements, include_decisions, include_action_items - } => { - // Get file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript".to_string(), - data: None, - }; - } - - // Analyze transcript - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - let analysis = match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(a) => a, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - }; - } - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - }; - } - }; - - // Create contract - let contract_name = name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = description.or(analysis.suggested_description.clone()); - - let contract_req = crate::db::models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - }; - } - }; - - let mut files_created = 0; - let mut tasks_created = 0; - - // Create requirements file if requested and there are requirements - if include_requirements && !analysis.requirements.is_empty() { - let requirements_items: Vec<String> = analysis.requirements - .iter() - .map(|req| format!("[{}] {}", req.speaker, req.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Extracted Requirements".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: requirements_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements".to_string()), - description: Some("Requirements extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("specify".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create decisions file if requested and there are decisions - if include_decisions && !analysis.decisions.is_empty() { - let decisions_items: Vec<String> = analysis.decisions - .iter() - .map(|dec| format!("[{}] {}", dec.speaker, dec.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Recorded Decisions".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: decisions_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions".to_string()), - description: Some("Decisions extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create tasks from action items if requested - if include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = CreateTaskRequest { - contract_id: Some(contract.id), - name: item.text.chars().take(100).collect(), - description: Some(format!("Action item from: {}", item.speaker)), - plan: item.text.clone(), - parent_task_id: None, - repository_url: None, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { - tasks_created += 1; - } - } - } - - ContractRequestResult { - success: true, - message: format!( - "Created contract '{}' with {} files and {} tasks from transcript analysis", - contract_name, files_created, tasks_created - ), - data: Some(json!({ - "contractId": contract.id, - "contractName": contract_name, - "filesCreated": files_created, - "tasksCreated": tasks_created, - "analysis": { - "requirementsCount": analysis.requirements.len(), - "decisionsCount": analysis.decisions.len(), - "actionItemsCount": analysis.action_items.len() - } - })), - } - } - - - } -} - -/// Get description and activities for a phase -fn get_phase_description(phase: &str) -> (String, Vec<String>) { - match phase { - "research" => ( - "Gather information, analyze competitors, and understand user needs".to_string(), - vec![ - "Conduct user research".to_string(), - "Analyze competitors".to_string(), - "Document findings".to_string(), - "Identify opportunities".to_string(), - ], - ), - "specify" => ( - "Define requirements, user stories, and acceptance criteria".to_string(), - vec![ - "Write requirements".to_string(), - "Create user stories".to_string(), - "Define acceptance criteria".to_string(), - "Document constraints".to_string(), - ], - ), - "plan" => ( - "Design architecture, create task breakdowns, and technical designs".to_string(), - vec![ - "Design system architecture".to_string(), - "Create technical specifications".to_string(), - "Break down into tasks".to_string(), - "Plan implementation order".to_string(), - ], - ), - "execute" => ( - "Implement features, write code, and run tasks".to_string(), - vec![ - "Implement features".to_string(), - "Write tests".to_string(), - "Track progress".to_string(), - "Document implementation details".to_string(), - ], - ), - "review" => ( - "Review work, create release notes, and conduct retrospectives".to_string(), - vec![ - "Review code and features".to_string(), - "Create release notes".to_string(), - "Conduct retrospective".to_string(), - "Document learnings".to_string(), - ], - ), - _ => ( - "Unknown phase".to_string(), - vec![], - ), - } -} - -/// Get the next phase in the lifecycle -fn get_next_phase(current: &str) -> Option<String> { - match current { - "research" => Some("specify".to_string()), - "specify" => Some("plan".to_string()), - "plan" => Some("execute".to_string()), - "execute" => Some("review".to_string()), - "review" => None, // Final phase - _ => None, - } -} - -/// Phase readiness analysis result -struct PhaseReadinessAnalysis { - ready: bool, - summary: String, - reasons: Vec<String>, - suggestions: Vec<String>, -} - -/// Analyze if the contract is ready to transition to the next phase -fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis { - let mut reasons = Vec::new(); - let mut suggestions = Vec::new(); - - match contract.contract.phase.as_str() { - "research" => { - // Check for research files - let research_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("research")) - .count(); - - if research_files == 0 { - reasons.push("No research documents created yet".to_string()); - suggestions.push("Create research notes or competitor analysis documents".to_string()); - } else { - reasons.push(format!("{} research document(s) created", research_files)); - } - - let ready = research_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Research phase has documentation. Consider transitioning to Specify phase.".to_string() - } else { - "Research phase needs more documentation before transitioning.".to_string() - }, - reasons, - suggestions, - } - } - "specify" => { - let spec_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("specify")) - .count(); - - if spec_files == 0 { - reasons.push("No specification documents created yet".to_string()); - suggestions.push("Create requirements or user stories documents".to_string()); - } else { - reasons.push(format!("{} specification document(s) created", spec_files)); - } - - let ready = spec_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Specification phase has documentation. Consider transitioning to Plan phase.".to_string() - } else { - "Specification phase needs requirements or user stories.".to_string() - }, - reasons, - suggestions, - } - } - "plan" => { - let plan_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("plan")) - .count(); - - let has_repos = !contract.repositories.is_empty(); - - if plan_files == 0 { - reasons.push("No planning documents created yet".to_string()); - suggestions.push("Create architecture or task breakdown documents".to_string()); - } else { - reasons.push(format!("{} planning document(s) created", plan_files)); - } - - if !has_repos { - reasons.push("No repositories configured".to_string()); - suggestions.push("Add a repository for task execution".to_string()); - } else { - reasons.push(format!("{} repository(ies) configured", contract.repositories.len())); - } - - let ready = plan_files > 0 && has_repos; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string() - } else { - "Planning phase needs documentation and/or repository configuration.".to_string() - }, - reasons, - suggestions, - } - } - "execute" => { - let total_tasks = contract.tasks.len(); - let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count(); - let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count(); - - if total_tasks == 0 { - reasons.push("No tasks created yet".to_string()); - suggestions.push("Create tasks to implement the planned work".to_string()); - } else { - reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks)); - } - - if running_tasks > 0 { - reasons.push(format!("{} task(s) still running", running_tasks)); - suggestions.push("Wait for running tasks to complete".to_string()); - } - - let ready = total_tasks > 0 && done_tasks == total_tasks; - - // For simple contracts, execute is the terminal phase - suggest completion - if ready && contract.contract.contract_type == "simple" { - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready, - summary: if ready { - if contract.contract.contract_type == "simple" { - "All tasks completed. Contract can be marked as completed.".to_string() - } else { - "All tasks completed. Ready for Review phase.".to_string() - } - } else if total_tasks == 0 { - "No tasks created yet. Create and complete tasks before reviewing.".to_string() - } else { - format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks) - }, - reasons, - suggestions, - } - } - "review" => { - let review_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("review")) - .count(); - - if review_files == 0 { - suggestions.push("Create review checklist or release notes".to_string()); - } else { - // Review documentation exists - suggest completion - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready: review_files > 0, - summary: if review_files > 0 { - "Review documentation complete. Contract can be marked as completed.".to_string() - } else { - "Review phase needs documentation before completion.".to_string() - }, - reasons: vec!["Review is the final phase".to_string()], - suggestions, - } - } - _ => PhaseReadinessAnalysis { - ready: false, - summary: "Unknown phase".to_string(), - reasons: vec!["Phase not recognized".to_string()], - suggestions: vec![], - }, - } -} - -// ============================================================================= -// Contract Chat History Endpoints -// ============================================================================= - -/// Get contract chat history -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Get or create conversation - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to get conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Get messages - let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::error!("Failed to list contract chat messages: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to list messages: {}", e) })), - ) - .into_response(); - } - }; - - ( - StatusCode::OK, - Json(ContractChatHistoryResponse { - contract_id, - conversation_id: conversation.id, - messages, - }), - ) - .into_response() -} - -/// Clear contract chat history (creates a new conversation) -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history cleared successfully"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn clear_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Clear conversation (archives existing and creates new) - match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(new_conversation) => { - ( - StatusCode::OK, - Json(json!({ - "message": "Chat history cleared", - "newConversationId": new_conversation.id - })), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to clear contract conversation: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to clear history: {}", e) })), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs deleted file mode 100644 index 5f56f06..0000000 --- a/makima/src/server/handlers/contract_daemon.rs +++ /dev/null @@ -1,936 +0,0 @@ -//! HTTP handlers for daemon-to-contract interaction. -//! -//! These endpoints allow tasks running in daemons to interact with their -//! associated contracts via the contract.sh script. Authentication is via -//! tool keys registered by the daemon when starting a task. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::FileSummary, repository}; -use crate::llm::phase_guidance::{self, PhaseChecklist, TaskInfo}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Contract status response for daemon. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractStatusResponse { - pub id: Uuid, - pub name: String, - pub phase: String, - pub status: String, - pub description: Option<String>, -} - -/// Contract goals response. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractGoalsResponse { - /// Description serves as goals for the contract - pub description: Option<String>, - pub phase: String, - pub phase_guidance: String, -} - -/// Progress report request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProgressReportRequest { - pub message: String, - #[serde(default)] - pub task_id: Option<Uuid>, -} - -/// Suggested action from server. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SuggestedActionResponse { - pub action: String, - pub description: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option<serde_json::Value>, -} - -/// Completion action request. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CompletionActionRequest { - #[serde(default)] - pub task_id: Option<Uuid>, - #[serde(default)] - pub files_modified: Vec<String>, - #[serde(default)] - pub lines_added: i32, - #[serde(default)] - pub lines_removed: i32, - #[serde(default)] - pub has_code_changes: bool, -} - -/// Recommended completion action. -#[derive(Debug, Clone, Serialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum CompletionAction { - Branch, - Merge, - Pr, - None, -} - -impl std::fmt::Display for CompletionAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CompletionAction::Branch => write!(f, "branch"), - CompletionAction::Merge => write!(f, "merge"), - CompletionAction::Pr => write!(f, "pr"), - CompletionAction::None => write!(f, "none"), - } - } -} - -/// Completion action response. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CompletionActionResponse { - pub action: String, - pub reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub branch_name: Option<String>, -} - -/// Create file request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateFileRequest { - pub name: String, - pub content: String, - #[serde(default)] - pub template_id: Option<String>, -} - -/// Update file request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DaemonUpdateFileRequest { - /// Content to update in the file (as markdown body element) - pub content: String, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// Get contract status for daemon. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract status", body = ContractStatusResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(contract)) => Json(ContractStatusResponse { - id: contract.id, - name: contract.name, - phase: contract.phase, - status: contract.status, - description: contract.description, - }) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get phase deliverables checklist. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/checklist", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Phase checklist", body = PhaseChecklist), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_checklist( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - // Get tasks for this contract - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t - .into_iter() - .map(|t| TaskInfo { - name: t.name, - status: t.status, - }) - .collect::<Vec<_>>(), - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - // Check if repository is configured - let has_repository = match repository::list_contract_repositories(pool, id).await { - Ok(repos) => !repos.is_empty(), - Err(_) => false, - }; - - let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); - - Json(checklist).into_response() -} - -/// Get contract goals. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/goals", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract goals", body = ContractGoalsResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_goals( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(contract)) => { - let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - Json(ContractGoalsResponse { - description: contract.description, - phase: contract.phase, - phase_guidance: deliverables.guidance, - }) - .into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Post progress report to contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/report", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ProgressReportRequest, - responses( - (status = 200, description = "Report received"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn post_progress_report( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ProgressReportRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Log the report as a contract event - let event_type = "progress_report"; - let payload = serde_json::json!({ - "message": req.message, - "task_id": req.task_id, - }); - - if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await { - tracing::warn!("Failed to create contract event: {}", e); - } - - Json(serde_json::json!({"status": "received"})).into_response() -} - -/// Get suggested action based on contract state. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/suggest-action", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Suggested action", body = SuggestedActionResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_suggest_action( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get completed deliverables and tasks for checklist - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id) - .await - .unwrap_or_default() - .into_iter() - .map(|t| TaskInfo { - name: t.name, - status: t.status, - }) - .collect::<Vec<_>>(); - - let has_repository = repository::list_contract_repositories(pool, id) - .await - .map(|r| !r.is_empty()) - .unwrap_or(false); - - let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); - - // Determine suggested action based on checklist - let (action, description) = if !checklist.suggestions.is_empty() { - ("follow_suggestion", checklist.suggestions.first().unwrap().clone()) - } else if checklist.completion_percentage >= 100 { - ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase)) - } else { - ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage)) - }; - - Json(SuggestedActionResponse { - action: action.to_string(), - description, - data: None, - }) - .into_response() -} - -/// Get recommended completion action. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/completion-action", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CompletionActionRequest, - responses( - (status = 200, description = "Recommended completion action", body = CompletionActionResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_completion_action( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CompletionActionRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Determine completion action based on phase and changes - let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0; - let has_significant_changes = req.lines_added + req.lines_removed > 50; - - let (action, reason) = match contract.phase.as_str() { - "research" | "specify" => { - if has_changes { - (CompletionAction::Merge, "Early phase changes can be merged directly".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "plan" => { - if has_significant_changes { - (CompletionAction::Pr, "Significant planning changes require review".to_string()) - } else if has_changes { - (CompletionAction::Merge, "Minor planning changes can be merged".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "execute" => { - if req.has_code_changes { - (CompletionAction::Pr, "Code changes in execute phase require review".to_string()) - } else if has_changes { - (CompletionAction::Branch, "Documentation changes can be branched".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "review" => { - if has_changes { - (CompletionAction::Pr, "Review phase changes should be reviewed".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - _ => (CompletionAction::None, "Unknown phase".to_string()), - }; - - // Generate branch name based on contract - let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) { - let slug = contract.name.to_lowercase().replace(' ', "-"); - Some(format!("contract/{}", slug)) - } else { - None - }; - - Json(CompletionActionResponse { - action: action.to_string(), - reason, - branch_name, - }) - .into_response() -} - -/// List contract files for daemon. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/files", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "List of contract files", body = Vec<FileSummary>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn list_contract_files( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => Json(files).into_response(), - Err(e) => { - tracing::error!("Failed to list files for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a specific contract file. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/files/{file_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("file_id" = Uuid, Path, description = "File ID") - ), - responses( - (status = 200, description = "File content"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or file not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, file_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Get file and verify it belongs to this contract - match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { - Ok(Some(file)) => { - if file.contract_id != Some(id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found in this contract")), - ) - .into_response(); - } - Json(file).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get file {}: {}", file_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract file. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/daemon/files/{file_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("file_id" = Uuid, Path, description = "File ID") - ), - request_body = DaemonUpdateFileRequest, - responses( - (status = 200, description = "File updated"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or file not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn update_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, file_id)): Path<(Uuid, Uuid)>, - Json(req): Json<DaemonUpdateFileRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Get file and verify it belongs to this contract - let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get file {}: {}", file_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - if file.contract_id != Some(id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found in this contract")), - ) - .into_response(); - } - - // Update the file with content parsed as markdown - let body = crate::llm::markdown_to_body(&req.content); - let update_req = crate::db::models::UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(body), - version: None, - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await { - Ok(Some(updated)) => Json(updated).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to update file {}: {}", file_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", format!("{}", e))), - ) - .into_response() - } - } -} - -/// Create a new contract file. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/files", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateFileRequest, - responses( - (status = 201, description = "File created"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn create_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateFileRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Create the file with content parsed as markdown - let body = crate::llm::markdown_to_body(&req.content); - let create_req = crate::db::models::CreateFileRequest { - contract_id: id, - name: Some(req.name), - description: None, - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, // Will be looked up from contract's current phase - }; - - match repository::create_file_for_owner(pool, auth.owner_id, create_req).await { - Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), - Err(e) => { - tracing::error!("Failed to create file for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs deleted file mode 100644 index 1f98f53..0000000 --- a/makima/src/server/handlers/contract_discuss.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! Discussion endpoint for LLM-powered contract creation. -//! -//! This handler provides an ephemeral conversation with Makima to help users -//! define and create contracts through natural dialogue. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::CreateContractRequest, repository}; -use crate::llm::{ - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS}, - LlmModel, ToolCall, ToolResult, UserQuestion, -}; -use crate::server::auth::Authenticated; -use crate::server::state::SharedState; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 10; - -/// System prompt for Makima character in contract discussions -const DISCUSS_SYSTEM_PROMPT: &str = r#" -You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. - -## Your Personality -- Professional yet personable -- Focused on understanding the user's actual needs -- Ask clarifying questions when requirements are vague -- Guide the conversation toward actionable outcomes -- Comfortable making recommendations based on experience - -## Your Goal -Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: -- A clear name and description -- The right contract type (simple, specification, or execute) -- Understanding of the scope and requirements - -## Contract Types -- **simple**: Quick tasks with minimal planning (plan -> execute phases only) -- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) -- **execute**: Direct implementation when requirements are already clear (execute phase only) - -## Guidelines -1. **Start by understanding**: Ask about what they want to build -2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? -3. **Gather requirements**: What are the must-haves vs nice-to-haves? -4. **Identify context**: Is there existing code? Which repository? -5. **Recommend type**: Suggest the appropriate contract type -6. **Confirm and create**: When the user is satisfied, create the contract - -## When to Create the Contract -Create the contract when: -- You have a clear understanding of what the user wants -- The user has confirmed they're ready to proceed -- You've gathered enough information for a meaningful contract - -Do NOT create the contract if: -- The user is still exploring ideas -- Key information is missing -- The user hasn't indicated readiness - -{transcript_context} -"#; - -/// Chat message in history -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -/// Request to discuss a potential contract -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractRequest { - /// The user's message - pub message: String, - /// Optional model selection (default: claude-sonnet) - #[serde(default)] - pub model: Option<String>, - /// Conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ChatMessage>>, - /// Optional transcript context from current session - #[serde(default)] - pub transcript_context: Option<String>, -} - -/// Response from the discussion endpoint -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractResponse { - /// Makima's response message - pub response: String, - /// Tool calls that were executed (e.g., create_contract) - pub tool_calls: Vec<ToolCallInfo>, - /// If a contract was created, its details - #[serde(skip_serializing_if = "Option::is_none")] - pub created_contract: Option<CreatedContractInfo>, - /// Pending questions (if LLM needs clarification) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -/// Information about a tool call that was executed -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Information about a created contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreatedContractInfo { - pub id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - pub contract_type: String, - pub initial_phase: String, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Discuss a potential contract with Makima -#[utoipa::path( - post, - path = "/api/v1/contracts/discuss", - request_body = DiscussContractRequest, - responses( - (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn discuss_contract_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<DiscussContractRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract discussion using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build system prompt with optional transcript context - let transcript_section = match &request.transcript_context { - Some(ctx) => format!( - "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", - ctx - ), - None => String::new(), - }; - - let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); - - // Run the discussion agentic loop - run_discuss_agentic_loop( - pool, - &llm_client, - system_prompt, - &request, - auth.owner_id, - ) - .await -} - -/// Run the agentic loop for contract discussion -async fn run_discuss_agentic_loop( - pool: &sqlx::PgPool, - llm_client: &LlmClient, - system_prompt: String, - request: &DiscussContractRequest, - owner_id: Uuid, -) -> axum::response::Response { - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add conversation history if provided - if let Some(history) = &request.history { - for msg in history { - messages.push(Message { - role: msg.role.clone(), - content: Some(msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // State for tracking - let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut created_contract: Option<CreatedContractInfo> = None; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract discussion loop iteration" - ); - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &DISCUSS_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &DISCUSS_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing discussion tool call"); - - // Parse the tool call - let mut execution_result = parse_discuss_tool_call(tool_call); - - // Handle async discussion tool requests - if let Some(discuss_request) = execution_result.request.take() { - let async_result = - handle_discuss_request(pool, discuss_request, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - - // Check if a contract was created - if let Some(ref data) = execution_result.data { - if let Some(contract_info) = data.get("createdContract") { - created_contract = Some(CreatedContractInfo { - id: contract_info["id"].as_str().unwrap_or("").to_string(), - name: contract_info["name"].as_str().unwrap_or("").to_string(), - description: contract_info["description"].as_str().map(|s| s.to_string()), - contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(), - initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(), - }); - } - } - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Discussion LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - "Done!".to_string() - } - }); - - ( - StatusCode::OK, - Json(DiscussContractResponse { - response: response_text, - tool_calls: all_tool_call_infos, - created_contract, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async discussion tool request -struct DiscussRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async discussion tool requests that require database access -async fn handle_discuss_request( - pool: &sqlx::PgPool, - request: DiscussToolRequest, - owner_id: Uuid, -) -> DiscussRequestResult { - match request { - DiscussToolRequest::CreateContract { - name, - description, - contract_type, - repository_url, - local_only, - } => { - // Create the contract request - let create_req = CreateContractRequest { - name: name.clone(), - description: Some(description.clone()), - contract_type: Some(contract_type.clone()), - template_id: None, - initial_phase: None, - autonomous_loop: None, - phase_guard: None, - local_only: Some(local_only), - auto_merge_local: None, - }; - - match repository::create_contract_for_owner(pool, owner_id, create_req).await { - Ok(contract) => { - // If repository URL was provided, try to add it - if let Some(repo_url) = repository_url { - // Try to add as remote repository - let add_result = repository::add_remote_repository( - pool, - contract.id, - &format!("{} Repository", name), - &repo_url, - true, // is_primary - ) - .await; - - if let Err(e) = add_result { - tracing::warn!( - "Failed to add repository to contract {}: {}", - contract.id, - e - ); - } - } - - DiscussRequestResult { - success: true, - message: format!("Contract '{}' created successfully!", contract.name), - data: Some(json!({ - "createdContract": { - "id": contract.id.to_string(), - "name": contract.name, - "description": contract.description, - "contractType": contract.contract_type, - "initialPhase": contract.phase, - } - })), - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - DiscussRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - } - } - } - } - } -} diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs deleted file mode 100644 index bdd4d40..0000000 --- a/makima/src/server/handlers/contracts.rs +++ /dev/null @@ -1,2376 +0,0 @@ -//! HTTP handlers for contract CRUD operations. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, - ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateManagedRepositoryRequest, PhaseChangeResult, - UpdateContractRequest, UpdateTaskRequest, -}; -use crate::db::repository::{self, RepositoryError}; -use crate::llm::PhaseDeliverables; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Deliverable Validation -// ============================================================================= - -/// Error type for deliverable validation failures -#[derive(Debug, Clone)] -pub struct DeliverableValidationError { - /// The error message with details about valid deliverables - pub message: String, -} - -impl DeliverableValidationError { - pub fn new(message: impl Into<String>) -> Self { - Self { - message: message.into(), - } - } -} - -impl std::fmt::Display for DeliverableValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for DeliverableValidationError {} - -/// Validates that a deliverable ID is valid for the given phase deliverables. -/// -/// # Arguments -/// * `deliverable_id` - The deliverable ID to validate -/// * `phase_deliverables` - The phase deliverables configuration to validate against -/// -/// # Returns -/// * `Ok(())` if the deliverable is valid -/// * `Err(DeliverableValidationError)` if the deliverable is not valid -pub fn validate_deliverable( - deliverable_id: &str, - phase_deliverables: &PhaseDeliverables, -) -> Result<(), DeliverableValidationError> { - let valid_deliverable = phase_deliverables - .deliverables - .iter() - .any(|d| d.id == deliverable_id); - - if valid_deliverable { - Ok(()) - } else { - let valid_ids: Vec<&str> = phase_deliverables - .deliverables - .iter() - .map(|d| d.id.as_str()) - .collect(); - - Err(DeliverableValidationError::new(format!( - "Invalid deliverable '{}' for {} phase. Valid IDs: [{}]", - deliverable_id, - phase_deliverables.phase, - valid_ids.join(", ") - ))) - } -} - -// ============================================================================= -// Supervisor Repository Update Helper -// ============================================================================= - -/// Helper function to update the supervisor task with repository info when a primary repo is added. -/// This ensures the supervisor has access to the repository when it starts. -async fn update_supervisor_with_repo_if_needed( - pool: &sqlx::PgPool, - contract_id: uuid::Uuid, - owner_id: uuid::Uuid, - repo: &ContractRepository, -) { - // Only update for primary repositories - if !repo.is_primary { - return; - } - - // Get the supervisor task - let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - tracing::debug!(contract_id = %contract_id, "No supervisor task found"); - return; - } - Err(e) => { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); - return; - } - }; - - // Only update if supervisor doesn't have a repository URL yet - if supervisor.repository_url.is_some() { - tracing::debug!( - supervisor_id = %supervisor.id, - "Supervisor already has repository URL" - ); - return; - } - - // Get repository URL (for remote repos) or local path (for local repos) - let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); - - if repo_url.is_none() && repo.source_type != "managed" { - tracing::debug!( - supervisor_id = %supervisor.id, - "Repository has no URL or path to assign" - ); - return; - } - - // Update supervisor task with repository info - let update_req = UpdateTaskRequest { - repository_url: repo_url, - version: Some(supervisor.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { - Ok(Some(updated)) => { - tracing::info!( - supervisor_id = %updated.id, - repository_url = ?updated.repository_url, - "Updated supervisor task with repository URL" - ); - } - Ok(None) => { - tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); - } - Err(e) => { - tracing::warn!( - supervisor_id = %supervisor.id, - error = %e, - "Failed to update supervisor with repository URL" - ); - } - } -} - -/// List all root contracts (no parent) for the authenticated user's owner. -#[utoipa::path( - get, - path = "/api/v1/contracts", - responses( - (status = 200, description = "List of root contracts", body = ContractListResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn list_contracts( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::list_contracts_for_owner(pool, auth.owner_id).await { - Ok(contracts) => { - let total = contracts.len() as i64; - Json(ContractListResponse { contracts, total }).into_response() - } - Err(e) => { - tracing::error!("Failed to list contracts: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a contract by ID with all its relations (repositories, files, tasks). -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract details with relations", body = ContractWithRelations), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get the contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get repositories - let repositories = match repository::list_contract_repositories(pool, id).await { - Ok(r) => r, - Err(e) => { - tracing::warn!("Failed to get repositories for {}: {}", id, e); - Vec::new() - } - }; - - // Get files - let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(f) => f, - Err(e) => { - tracing::warn!("Failed to get files for contract {}: {}", id, e); - Vec::new() - } - }; - - // Get tasks - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - Json(ContractWithRelations { - contract, - repositories, - files, - tasks, - }) - .into_response() -} - -/// Create a new contract. -#[utoipa::path( - post, - path = "/api/v1/contracts", - request_body = CreateContractRequest, - responses( - (status = 201, description = "Contract created", body = ContractSummary), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<CreateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { - Ok(contract) => { - // Create supervisor task for this contract - let supervisor_name = format!("{} Supervisor", contract.name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - contract.name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - // Get repository info from contract if available - let repo_url = { - // Try to get the first repository associated with this contract - match repository::list_contract_repositories(pool, contract.id).await { - Ok(repos) if !repos.is_empty() => { - let repo = &repos[0]; - repo.repository_url.clone() - } - _ => None, - } - }; - - let supervisor_req = crate::db::models::CreateTaskRequest { - name: supervisor_name, - description: None, - plan: supervisor_plan, - repository_url: repo_url, - base_branch: None, - target_branch: None, - parent_task_id: None, - contract_id: Some(contract.id), - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Supervisor uses its own worktree - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { - Ok(supervisor_task) => { - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - is_supervisor = supervisor_task.is_supervisor, - "Created supervisor task for contract" - ); - - // Update contract with supervisor_task_id - let update_req = crate::db::models::UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to link supervisor task to contract" - ); - } - } - Err(e) => { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create supervisor task for contract" - ); - } - } - - // Record history event for contract creation - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("created"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "type": &contract.contract_type, - "description": &contract.description, - }), - ).await; - - // Get the summary version with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), - Ok(None) => { - // Shouldn't happen, but return basic info if it does - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - Err(e) => { - tracing::warn!("Failed to get contract summary: {}", e); - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = UpdateContractRequest, - responses( - (status = 200, description = "Contract updated", body = ContractSummary), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn update_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<UpdateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await { - Ok(Some(contract)) => { - // If contract is completed, stop the supervisor task and clean up worktrees - if contract.status == "completed" { - if let Some(supervisor_task_id) = contract.supervisor_task_id { - // Get the supervisor task to find its daemon - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - if let Some(daemon_id) = supervisor.daemon_id { - let state_clone = state.clone(); - tokio::spawn(async move { - // Gracefully interrupt the supervisor - let cmd = crate::server::state::DaemonCommand::InterruptTask { - task_id: supervisor_task_id, - graceful: true, - }; - if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { - tracing::warn!( - supervisor_task_id = %supervisor_task_id, - daemon_id = %daemon_id, - error = %e, - "Failed to stop supervisor task on contract completion" - ); - } else { - tracing::info!( - supervisor_task_id = %supervisor_task_id, - contract_id = %id, - "Stopped supervisor task on contract completion" - ); - } - }); - } - } - } - - // Clean up all task worktrees for this contract - let pool_clone = pool.clone(); - let state_clone = state.clone(); - let contract_id = id; - tokio::spawn(async move { - cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await; - }); - - // Record history event for contract completion - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("completed"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "status": &contract.status, - }), - ).await; - - } - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }) - .into_response(), - } - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(RepositoryError::VersionConflict { expected, actual }) => { - tracing::info!( - "Version conflict on contract {}: expected {}, actual {}", - id, - expected, - actual - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": format!( - "Contract was modified. Expected version {}, actual version {}", - expected, actual - ), - "expectedVersion": expected, - "actualVersion": actual, - })), - ) - .into_response() - } - Err(RepositoryError::Database(e)) => { - tracing::error!("Failed to update contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 204, description = "Contract deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Clean up any pending supervisor questions for this contract - state.remove_pending_questions_for_contract(id); - - // Clean up all task worktrees BEFORE deleting the contract - // (because CASCADE delete will remove tasks from DB) - cleanup_contract_worktrees(pool, &state, id).await; - - match repository::delete_contract_for_owner(pool, id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Repository Management -// ============================================================================= - -/// Add a remote repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/remote", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddRemoteRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_remote_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddRemoteRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - Some(&req.repository_url), - None, - "remote", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add remote repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Add a local repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/local", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddLocalRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_local_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddLocalRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - None, - Some(&req.local_path), - "local", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add local repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a managed repository (daemon will create it). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/managed", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateManagedRepositoryRequest, - responses( - (status = 201, description = "Repository creation requested", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_managed_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateManagedRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { - Ok(repo) => { - // For managed repos, the daemon will create the repo and we'll update later - // For now, just mark that this is a managed repo configuration - // The helper handles the case where repo has no URL yet - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!( - "Failed to create managed repository for contract {}: {}", - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a repository from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/repositories/{repo_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository removed"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_contract_repository(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to delete repository {} from contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Set a repository as primary for a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository set as primary"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn set_repository_primary( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::set_repository_primary(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to set repository {} as primary for contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Task Association -// ============================================================================= - -/// Add a task to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task added to contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_task_to_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify task exists and belongs to owner - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get task {}: {}", task_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Remove a task from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task removed from contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn remove_task_from_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to remove task {} from contract {}: {}", - task_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Phase Management -// ============================================================================= - -/// Change contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/phase", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ChangePhaseRequest, - responses( - (status = 200, description = "Phase changed", body = ContractSummary), - (status = 400, description = "Validation failed", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn change_phase( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ChangePhaseRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, get the contract to check phase_guard - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // If phase_guard is enabled and not confirmed, return phase deliverables for review - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if contract.phase_guard && !req.confirmed.unwrap_or(false) { - // If user provided feedback, return it - if let Some(ref feedback) = req.feedback { - return Json(serde_json::json!({ - "status": "changes_requested", - "currentPhase": contract.phase, - "requestedPhase": req.phase, - "feedback": feedback, - "message": "Feedback has been noted. Address the changes and try again." - })) - .into_response(); - } - - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) - .map(|f| serde_json::json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| serde_json::json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| serde_json::json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - contract.phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return Json(serde_json::json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": contract.phase, - "nextPhase": req.phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required." - })) - .into_response(); - } - - // Phase guard is disabled or user confirmed - proceed with phase change - // Use the version-checking function for explicit conflict detection - match repository::change_contract_phase_with_version( - pool, - id, - auth.owner_id, - &req.phase, - req.expected_version, - ) - .await - { - Ok(PhaseChangeResult::Success(updated_contract)) => { - // Save supervisor state on phase change (Task 3.3) - // This is a key save point for restoration - let new_phase_for_state = updated_contract.phase.clone(); - let contract_id_for_state = updated_contract.id; - let pool_for_state = pool.clone(); - tokio::spawn(async move { - if let Err(e) = repository::update_supervisor_phase(&pool_for_state, contract_id_for_state, &new_phase_for_state).await { - tracing::warn!( - contract_id = %contract_id_for_state, - new_phase = %new_phase_for_state, - error = %e, - "Failed to save supervisor state on phase change" - ); - } - }); - - // Notify supervisor of phase change - if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - let state_clone = state.clone(); - let contract_id = updated_contract.id; - let new_phase = updated_contract.phase.clone(); - tokio::spawn(async move { - state_clone.notify_supervisor_of_phase_change( - supervisor.id, - supervisor.daemon_id, - contract_id, - &new_phase, - ).await; - }); - } - } - - // Record history event for phase change - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "phase", - Some("changed"), - Some(&contract.phase), - serde_json::json!({ - "contractName": &contract.name, - "newPhase": &updated_contract.phase, - }), - ).await; - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: updated_contract.id, - name: updated_contract.name, - description: updated_contract.description, - contract_type: updated_contract.contract_type, - phase: updated_contract.phase, - status: updated_contract.status, - supervisor_task_id: updated_contract.supervisor_task_id, - local_only: updated_contract.local_only, - auto_merge_local: updated_contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: updated_contract.version, - created_at: updated_contract.created_at, - }) - .into_response(), - } - } - Ok(PhaseChangeResult::VersionConflict { expected, actual, current_phase }) => { - tracing::info!( - contract_id = %id, - expected_version = expected, - actual_version = actual, - current_phase = %current_phase, - "Phase change failed due to version conflict" - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": "Phase change failed due to concurrent modification", - "details": { - "expected_version": expected, - "actual_version": actual, - "current_phase": current_phase - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::ValidationFailed { reason, missing_requirements }) => { - tracing::warn!( - contract_id = %id, - reason = %reason, - "Phase change validation failed" - ); - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "VALIDATION_FAILED", - "message": reason, - "details": { - "missing_requirements": missing_requirements - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::NotFound) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Ok(PhaseChangeResult::Unauthorized) => ( - StatusCode::UNAUTHORIZED, - Json(ApiError::new("UNAUTHORIZED", "Not authorized to change this contract's phase")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to change phase for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Deliverables -// ============================================================================= - -/// Request body for marking a deliverable complete -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MarkDeliverableRequest { - /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request') - pub deliverable_id: String, - /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. - pub phase: Option<String>, -} - -/// Mark a deliverable as complete for a contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/deliverables/complete", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = MarkDeliverableRequest, - responses( - (status = 200, description = "Deliverable marked complete", body = serde_json::Value), - (status = 400, description = "Invalid deliverable ID", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn mark_deliverable_complete( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<MarkDeliverableRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Use specified phase or default to current contract phase - let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - // Use custom phase_config if present, otherwise fall back to built-in contract types - let phase_config = contract.get_phase_config(); - let phase_deliverables = crate::llm::get_phase_deliverables_with_config( - &target_phase, - &contract.contract_type, - phase_config.as_ref(), - ); - - // Validate deliverable exists - if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "INVALID_DELIVERABLE", - "message": validation_error.message, - })), - ) - .into_response(); - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &req.deliverable_id) { - return Json(serde_json::json!({ - "success": true, - "message": format!("Deliverable '{}' is already marked complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })) - .into_response(); - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, id, &target_phase, &req.deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - Json(serde_json::json!({ - "success": true, - "message": format!("Marked deliverable '{}' as complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })) - .into_response() - } - Err(e) => { - tracing::error!("Failed to mark deliverable complete for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Events -// ============================================================================= - -/// Get contract event history. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/events", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_events( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_contract_events(pool, id).await { - Ok(events) => Json(events).into_response(), - Err(e) => { - tracing::error!("Failed to get events for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Internal Helper Functions -// ============================================================================= - -/// Clean up all worktrees for tasks in a contract. -/// -/// This is called when a contract is completed or deleted to remove -/// all associated task worktrees from connected daemons. -async fn cleanup_contract_worktrees( - pool: &sqlx::PgPool, - state: &SharedState, - contract_id: Uuid, -) { - tracing::info!( - contract_id = %contract_id, - "Cleaning up worktrees for contract tasks" - ); - - // Get all tasks with worktree info for this contract - let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await { - Ok(tasks) => tasks, - Err(e) => { - tracing::error!( - contract_id = %contract_id, - error = %e, - "Failed to list tasks for worktree cleanup" - ); - return; - } - }; - - if tasks.is_empty() { - tracing::debug!( - contract_id = %contract_id, - "No tasks with worktrees to clean up" - ); - return; - } - - tracing::info!( - contract_id = %contract_id, - task_count = tasks.len(), - "Found tasks with worktrees to clean up" - ); - - // Send cleanup command to each task's daemon - // Skip tasks that share a supervisor's worktree (they don't own the worktree) - for task in tasks { - // Skip tasks that reuse the supervisor's worktree - the supervisor owns it - if task.supervisor_worktree_task_id.is_some() { - tracing::debug!( - task_id = %task.id, - supervisor_worktree_task_id = ?task.supervisor_worktree_task_id, - contract_id = %contract_id, - "Task shares supervisor worktree, skipping worktree cleanup" - ); - continue; - } - - if let Some(daemon_id) = task.daemon_id { - let cmd = crate::server::state::DaemonCommand::CleanupWorktree { - task_id: task.id, - delete_branch: true, // Delete the branch when contract is done - }; - - match state.send_daemon_command(daemon_id, cmd).await { - Ok(()) => { - tracing::info!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - "Sent worktree cleanup command" - ); - } - Err(e) => { - tracing::warn!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - error = %e, - "Failed to send worktree cleanup command (daemon may be offline)" - ); - } - } - } else { - tracing::debug!( - task_id = %task.id, - contract_id = %contract_id, - "Task has no daemon assigned, skipping worktree cleanup" - ); - } - } -} - -// ============================================================================= -// Supervisor Status API -// ============================================================================= - -/// Query parameters for supervisor heartbeat history -#[derive(Debug, Deserialize)] -pub struct HeartbeatHistoryQuery { - /// Maximum number of heartbeats to return (default: 10) - pub limit: Option<i32>, - /// Offset for pagination (default: 0) - pub offset: Option<i32>, -} - -/// Get supervisor status for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - let supervisor_task_id = match contract.supervisor_task_id { - Some(task_id) => task_id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - }; - - // Get supervisor status from supervisor_states table - match repository::get_supervisor_status(pool, id, auth.owner_id).await { - Ok(Some(status_info)) => { - // Determine if supervisor is actively running - let is_running = status_info.is_running && status_info.task_status == "running"; - - let response = crate::db::models::SupervisorStatusResponse { - task_id: status_info.task_id, - state: status_info.supervisor_state, - phase: status_info.phase, - current_activity: status_info.current_activity, - progress: None, // We don't track progress percentage yet - last_heartbeat: status_info.last_heartbeat, - pending_task_ids: status_info.pending_task_ids, - is_running, - }; - Json(response).into_response() - } - Ok(None) => { - // No supervisor state record exists, but supervisor task might exist - // Try to get info from the task itself - match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - Ok(Some(task)) => { - let is_running = task.daemon_id.is_some() && task.status == "running"; - let response = crate::db::models::SupervisorStatusResponse { - task_id: task.id, - state: task.status.clone(), - phase: contract.phase.clone(), - current_activity: task.progress_summary.clone(), - progress: None, - last_heartbeat: task.updated_at, - pending_task_ids: Vec::new(), - is_running, - }; - Json(response).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to get supervisor status for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get supervisor heartbeat history for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/heartbeats", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("limit" = Option<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"), - ("offset" = Option<i32>, Query, description = "Offset for pagination (default: 0)") - ), - responses( - (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_heartbeats( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - let limit = query.limit.unwrap_or(10).min(100); // Cap at 100 - let offset = query.offset.unwrap_or(0); - - // Get activity history as heartbeats - let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await { - Ok(activities) => activities, - Err(e) => { - tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get total count for pagination - let total = match repository::count_supervisor_activity_history(pool, id).await { - Ok(count) => count, - Err(e) => { - tracing::warn!("Failed to count supervisor heartbeats: {}", e); - activities.len() as i64 - } - }; - - // Convert to heartbeat entries - let heartbeats: Vec<crate::db::models::SupervisorHeartbeatEntry> = activities - .into_iter() - .map(|a| crate::db::models::SupervisorHeartbeatEntry { - timestamp: a.timestamp, - state: a.state, - activity: a.activity, - progress: a.progress.map(|p| p as u8), - phase: a.phase, - pending_task_ids: a.pending_task_ids, - }) - .collect(); - - Json(crate::db::models::SupervisorHeartbeatHistoryResponse { - heartbeats, - total, - }) - .into_response() -} - -/// Sync supervisor state (refresh last_activity timestamp). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/supervisor/sync", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn sync_supervisor( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - if contract.supervisor_task_id.is_none() { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - - // Sync supervisor state (update last_activity) - match repository::sync_supervisor_state(pool, id).await { - Ok(Some(_state)) => { - // Get task status to determine current state - let task_status = if let Some(task_id) = contract.supervisor_task_id { - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(task)) => task.status, - _ => "unknown".to_string(), - } - } else { - "unknown".to_string() - }; - - Json(crate::db::models::SupervisorSyncResponse { - synced: true, - state: task_status, - message: Some("Supervisor state synced successfully".to_string()), - }) - .into_response() - } - Ok(None) => { - // No supervisor state exists, return not found - ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::models::{DeliverableDefinition, PhaseConfig, PhaseDefinition}; - use crate::llm::{get_phase_deliverables_for_type, get_phase_deliverables_with_config}; - use std::collections::HashMap; - - #[test] - fn test_validate_deliverable_valid_simple_plan() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_valid_simple_execute() { - let phase_deliverables = get_phase_deliverables_for_type("execute", "simple"); - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_invalid_id() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("nonexistent-deliverable", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("nonexistent-deliverable")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_validate_deliverable_specification_phases() { - // Research phase - let phase_deliverables = get_phase_deliverables_for_type("research", "specification"); - assert!(validate_deliverable("research-notes", &phase_deliverables).is_ok()); - assert!(validate_deliverable("invalid", &phase_deliverables).is_err()); - - // Specify phase - let phase_deliverables = get_phase_deliverables_for_type("specify", "specification"); - assert!(validate_deliverable("requirements-document", &phase_deliverables).is_ok()); - assert!(validate_deliverable("plan-document", &phase_deliverables).is_err()); - - // Review phase - let phase_deliverables = get_phase_deliverables_for_type("review", "specification"); - assert!(validate_deliverable("release-notes", &phase_deliverables).is_ok()); - } - - #[test] - fn test_validate_deliverable_execute_type_no_deliverables() { - // Execute-only contracts have no deliverables - let phase_deliverables = get_phase_deliverables_for_type("execute", "execute"); - // Any deliverable should fail since there are none - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Valid IDs: []")); - } - - #[test] - fn test_validate_deliverable_with_custom_phase_config() { - // Create a custom phase config - let mut deliverables = HashMap::new(); - deliverables.insert( - "design".to_string(), - vec![ - DeliverableDefinition { - id: "architecture-doc".to_string(), - name: "Architecture Document".to_string(), - priority: "required".to_string(), - }, - DeliverableDefinition { - id: "api-spec".to_string(), - name: "API Specification".to_string(), - priority: "recommended".to_string(), - }, - ], - ); - - let phase_config = PhaseConfig { - phases: vec![ - PhaseDefinition { - id: "design".to_string(), - name: "Design".to_string(), - order: 0, - }, - PhaseDefinition { - id: "build".to_string(), - name: "Build".to_string(), - order: 1, - }, - ], - default_phase: "design".to_string(), - deliverables, - }; - - // Validate against custom config - let phase_deliverables = - get_phase_deliverables_with_config("design", "custom", Some(&phase_config)); - - // Valid custom deliverables - assert!(validate_deliverable("architecture-doc", &phase_deliverables).is_ok()); - assert!(validate_deliverable("api-spec", &phase_deliverables).is_ok()); - - // Invalid deliverable for custom config - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("plan-document")); - assert!(err.message.contains("architecture-doc")); - assert!(err.message.contains("api-spec")); - } - - #[test] - fn test_validate_deliverable_error_message_format() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("xyz", &phase_deliverables); - let err = result.unwrap_err(); - - // Check error message format matches the specification - assert!(err.message.contains("Invalid deliverable 'xyz'")); - assert!(err.message.contains("plan phase")); - assert!(err.message.contains("Valid IDs:")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_deliverable_validation_error_display() { - let err = DeliverableValidationError::new("Test error message"); - assert_eq!(format!("{}", err), "Test error message"); - } - - #[test] - fn test_validate_deliverable_unknown_phase() { - // Unknown phase should return empty deliverables - let phase_deliverables = get_phase_deliverables_for_type("unknown", "simple"); - let result = validate_deliverable("any-id", &phase_deliverables); - assert!(result.is_err()); - } -} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index ac5652a..63b1827 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -122,7 +122,11 @@ pub async fn list_tasks( }; let result = if query.orphan { - repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await + // Backed by the per-owner tmp directive going forward — see + // `list_tmp_tasks_for_owner` for the semantics. The query parameter + // name (`?orphan=true`) is preserved for backwards compatibility + // with existing frontend callers. + repository::list_tmp_tasks_for_owner(pool, auth.owner_id).await } else { repository::list_tasks_for_owner(pool, auth.owner_id).await }; @@ -228,7 +232,7 @@ pub async fn get_task( pub async fn create_task( State(state): State<SharedState>, Authenticated(auth): Authenticated, - Json(req): Json<CreateTaskRequest>, + Json(mut req): Json<CreateTaskRequest>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -238,6 +242,32 @@ pub async fn create_task( .into_response(); }; + // Every top-level task must live under SOME directive going forward — + // the unified directive surface is the only way users see tasks. If a + // caller doesn't supply directive_id, attach to the owner's tmp + // (scratchpad) directive, auto-creating it if needed. Subtasks + // (parent_task_id set) inherit their parent's directive linkage and + // are fine without an explicit directive_id. + if req.directive_id.is_none() && req.parent_task_id.is_none() { + match repository::get_or_create_tmp_directive(pool, auth.owner_id).await { + Ok(tmp) => { + req.directive_id = Some(tmp.id); + } + Err(e) => { + tracing::error!( + owner_id = %auth.owner_id, + error = %e, + "Failed to provision tmp directive for orphan task" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("TMP_PROVISION_FAILED", &e.to_string())), + ) + .into_response(); + } + } + } + match repository::create_task_for_owner(pool, auth.owner_id, req).await { Ok(task) => { // Record history event for task creation diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 4bdb424..c761dcc 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,12 +1,11 @@ //! HTTP and WebSocket request handlers. +//! +//! Phase 5 removed: contract_chat, contract_daemon, contract_discuss, +//! contracts, transcript_analysis. Contracts subsystem is gone. pub mod api_keys; pub mod chat; -pub mod contract_chat; -pub mod contract_daemon; -pub mod contract_discuss; pub mod daemon_download; -pub mod contracts; pub mod directives; pub mod file_ws; pub mod files; @@ -23,6 +22,5 @@ pub mod repository_history; pub mod speak; pub mod templates; pub mod voice; -pub mod transcript_analysis; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs deleted file mode 100644 index 9261c0c..0000000 --- a/makima/src/server/handlers/transcript_analysis.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! HTTP handlers for transcript analysis and contract integration. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models, repository}; -use crate::llm::transcript_analyzer::{ - TranscriptAnalysisResult, build_analysis_prompt, calculate_speaker_stats, - format_transcript_for_analysis, parse_analysis_response, -}; -use crate::llm::claude::{ClaudeClient, ClaudeModel, Message, MessageContent}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Request to analyze a file's transcript -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeTranscriptRequest { - /// File ID containing the transcript to analyze - pub file_id: Uuid, -} - -/// Response from transcript analysis -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeTranscriptResponse { - pub file_id: Uuid, - pub analysis: TranscriptAnalysisResult, -} - -/// Request to create a contract from analysis -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractFromAnalysisRequest { - /// File ID containing the analyzed transcript - pub file_id: Uuid, - /// Override the suggested name (optional) - pub name: Option<String>, - /// Override the suggested description (optional) - pub description: Option<String>, - /// Include requirements as file content (default: true) - #[serde(default = "default_true")] - pub include_requirements: bool, - /// Include decisions as file content (default: true) - #[serde(default = "default_true")] - pub include_decisions: bool, - /// Include action items as tasks (default: true) - #[serde(default = "default_true")] - pub include_action_items: bool, -} - -fn default_true() -> bool { - true -} - -/// Response from creating contract from analysis -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractFromAnalysisResponse { - pub contract_id: Uuid, - pub contract_name: String, - pub files_created: Vec<FileCreatedInfo>, - pub tasks_created: Vec<TaskCreatedInfo>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct FileCreatedInfo { - pub id: Uuid, - pub name: String, - pub file_type: String, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct TaskCreatedInfo { - pub id: Uuid, - pub name: String, -} - -/// Request to update an existing contract from analysis -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractFromAnalysisRequest { - /// File ID containing the transcript - pub file_id: Uuid, - /// Contract ID to update - pub contract_id: Uuid, - /// Add requirements to contract files - #[serde(default = "default_true")] - pub add_requirements: bool, - /// Add decisions to contract files - #[serde(default = "default_true")] - pub add_decisions: bool, - /// Create tasks from action items - #[serde(default = "default_true")] - pub create_tasks: bool, -} - -/// Response from updating contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractFromAnalysisResponse { - pub contract_id: Uuid, - pub files_updated: Vec<Uuid>, - pub tasks_created: Vec<TaskCreatedInfo>, - pub analysis_summary: String, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// Analyze a file's transcript to extract requirements, decisions, and action items. -#[utoipa::path( - post, - path = "/api/v1/listen/analyze", - request_body = AnalyzeTranscriptRequest, - responses( - (status = 200, description = "Transcript analyzed", body = AnalyzeTranscriptResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn analyze_transcript( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<AnalyzeTranscriptRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ).into_response(); - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - // Check if transcript is empty - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript to analyze")), - ).into_response(); - } - - // Analyze the transcript - match analyze_transcript_internal(&file.transcript).await { - Ok(analysis) => { - Json(AnalyzeTranscriptResponse { - file_id: request.file_id, - analysis, - }).into_response() - } - Err(e) => { - tracing::error!(error = %e, "Failed to analyze transcript"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response() - } - } -} - -/// Create a new contract from an analyzed transcript. -#[utoipa::path( - post, - path = "/api/v1/listen/create-contract", - request_body = CreateContractFromAnalysisRequest, - responses( - (status = 201, description = "Contract created", body = CreateContractFromAnalysisResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn create_contract_from_analysis( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<CreateContractFromAnalysisRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ).into_response(); - }; - - // Get the file with transcript - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), - ).into_response(); - } - - // Analyze transcript - let analysis = match analyze_transcript_internal(&file.transcript).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response(); - } - }; - - // Determine contract name and description - let contract_name = request.name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = request.description - .or(analysis.suggested_description.clone()); - - // Create the contract - let contract_req = models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %e, "Failed to create contract"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - let mut files_created: Vec<FileCreatedInfo> = Vec::new(); - let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new(); - - // Create requirements file if we have requirements - if request.include_requirements && !analysis.requirements.is_empty() { - let body = build_requirements_body(&analysis.requirements); - let file_req = models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements from Transcript".to_string()), - description: Some("Requirements extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_created.push(FileCreatedInfo { - id: f.id, - name: f.name, - file_type: "requirements".to_string(), - }); - } - } - - // Create decisions file if we have decisions - if request.include_decisions && !analysis.decisions.is_empty() { - let body = build_decisions_body(&analysis.decisions); - let file_req = models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions from Transcript".to_string()), - description: Some("Decisions extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_created.push(FileCreatedInfo { - id: f.id, - name: f.name, - file_type: "decisions".to_string(), - }); - } - } - - // Create tasks from action items - if request.include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = models::CreateTaskRequest { - contract_id: Some(contract.id), - name: truncate_for_name(&item.text, 100), - description: Some(format!("Action item from transcript (Speaker: {})", item.speaker)), - plan: item.text.clone(), - repository_url: None, - base_branch: None, - target_branch: None, - parent_task_id: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { - tasks_created.push(TaskCreatedInfo { - id: t.id, - name: t.name, - }); - } - } - } - - ( - StatusCode::CREATED, - Json(CreateContractFromAnalysisResponse { - contract_id: contract.id, - contract_name, - files_created, - tasks_created, - }), - ).into_response() -} - -/// Update an existing contract with information from transcript analysis. -#[utoipa::path( - post, - path = "/api/v1/listen/update-contract", - request_body = UpdateContractFromAnalysisRequest, - responses( - (status = 200, description = "Contract updated", body = UpdateContractFromAnalysisResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File or contract not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn update_contract_from_analysis( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<UpdateContractFromAnalysisRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ).into_response(); - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - // Verify contract exists - let _contract = match repository::get_contract_for_owner(pool, request.contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get contract"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), - ).into_response(); - } - - // Analyze transcript - let analysis = match analyze_transcript_internal(&file.transcript).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response(); - } - }; - - let mut files_updated: Vec<Uuid> = Vec::new(); - let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new(); - - // Create or update requirements file - if request.add_requirements && !analysis.requirements.is_empty() { - let body = build_requirements_body(&analysis.requirements); - let file_req = models::CreateFileRequest { - contract_id: request.contract_id, - name: Some(format!("Requirements from {}", file.name)), - description: Some("Requirements extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_updated.push(f.id); - } - } - - // Create or update decisions file - if request.add_decisions && !analysis.decisions.is_empty() { - let body = build_decisions_body(&analysis.decisions); - let file_req = models::CreateFileRequest { - contract_id: request.contract_id, - name: Some(format!("Decisions from {}", file.name)), - description: Some("Decisions extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_updated.push(f.id); - } - } - - // Create tasks from action items - if request.create_tasks && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = models::CreateTaskRequest { - contract_id: Some(request.contract_id), - name: truncate_for_name(&item.text, 100), - description: Some(format!("Action item from {} (Speaker: {})", file.name, item.speaker)), - plan: item.text.clone(), - repository_url: None, - base_branch: None, - target_branch: None, - parent_task_id: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { - tasks_created.push(TaskCreatedInfo { - id: t.id, - name: t.name, - }); - } - } - } - - let summary = format!( - "Extracted {} requirements, {} decisions, {} action items from transcript", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ); - - Json(UpdateContractFromAnalysisResponse { - contract_id: request.contract_id, - files_updated, - tasks_created, - analysis_summary: summary, - }).into_response() -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -/// Analyze transcript using Claude -async fn analyze_transcript_internal( - transcript: &[models::TranscriptEntry], -) -> Result<TranscriptAnalysisResult, String> { - let transcript_text = format_transcript_for_analysis(transcript); - let speaker_stats = calculate_speaker_stats(transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Create Claude client - let client = ClaudeClient::from_env(ClaudeModel::Sonnet) - .map_err(|e| format!("Failed to create Claude client: {}", e))?; - - // Call Claude API with empty tools to make a simple chat call - let messages = vec![Message { - role: "user".to_string(), - content: MessageContent::Text(prompt), - }]; - - let result = client.chat_with_tools(messages, &[]).await - .map_err(|e| format!("Claude API error: {}", e))?; - - // Parse the response - let content = result.content.ok_or_else(|| "No response content from Claude".to_string())?; - parse_analysis_response(&content, speaker_stats) -} - -/// Build file body elements from requirements -fn build_requirements_body(requirements: &[crate::llm::transcript_analyzer::ExtractedRequirement]) -> Vec<models::BodyElement> { - let mut body = vec![ - models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - ]; - - // Group by category if available - let mut functional = Vec::new(); - let mut technical = Vec::new(); - let mut other = Vec::new(); - - for req in requirements { - match req.category.as_deref() { - Some("functional") => functional.push(req), - Some("technical") => technical.push(req), - _ => other.push(req), - } - } - - if !functional.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Functional Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: functional.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - if !technical.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Technical Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: technical.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - if !other.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Other Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: other.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - body -} - -/// Build file body elements from decisions -fn build_decisions_body(decisions: &[crate::llm::transcript_analyzer::ExtractedDecision]) -> Vec<models::BodyElement> { - let mut body = vec![ - models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - ]; - - let items: Vec<String> = decisions.iter().map(|d| { - let context = d.context.as_ref().map(|c| format!(" (Context: {})", c)).unwrap_or_default(); - format!("**{}**: {}{}", d.speaker, d.text, context) - }).collect(); - - body.push(models::BodyElement::List { - ordered: true, - items, - }); - - body -} - -/// Truncate text to fit as a task name -fn truncate_for_name(text: &str, max_len: usize) -> String { - if text.len() <= max_len { - text.to_string() - } else { - format!("{}...", &text[..max_len - 3]) - } -} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index efae901..59eff2e 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -45,10 +45,8 @@ pub fn make_router(state: SharedState) -> Router { let api_v1 = Router::new() .route("/listen", get(listen::websocket_handler)) .route("/speak", get(speak::websocket_handler)) - // Listen/transcript analysis endpoints - .route("/listen/analyze", post(transcript_analysis::analyze_transcript)) - .route("/listen/create-contract", post(transcript_analysis::create_contract_from_analysis)) - .route("/listen/update-contract", post(transcript_analysis::update_contract_from_analysis)) + // Listen/transcript-analysis endpoints removed in Phase 5 with the + // contracts subsystem. .route("/files/subscribe", get(file_ws::file_subscription_handler)) .route("/files", get(files::list_files).post(files::create_file)) .route( @@ -167,68 +165,9 @@ pub fn make_router(state: SharedState) -> Router { get(users::get_user_settings_handler) .put(users::update_user_settings_handler), ) - // Contract endpoints - .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler)) - .route( - "/contracts", - get(contracts::list_contracts).post(contracts::create_contract), - ) - .route( - "/contracts/{id}", - get(contracts::get_contract) - .put(contracts::update_contract) - .delete(contracts::delete_contract), - ) - .route("/contracts/{id}/phase", post(contracts::change_phase)) - .route("/contracts/{id}/deliverables/complete", post(contracts::mark_deliverable_complete)) - .route("/contracts/{id}/events", get(contracts::get_events)) - .route("/contracts/{id}/chat", post(contract_chat::contract_chat_handler)) - .route( - "/contracts/{id}/chat/history", - get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history), - ) - // Contract supervisor resume endpoints - .route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor)) - .route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation)) - // Contract supervisor status endpoints - .route("/contracts/{id}/supervisor/status", get(contracts::get_supervisor_status)) - .route("/contracts/{id}/supervisor/heartbeats", get(contracts::get_supervisor_heartbeats)) - .route("/contracts/{id}/supervisor/sync", post(contracts::sync_supervisor)) - // History endpoints - .route("/contracts/{id}/history", get(history::get_contract_history)) - .route("/contracts/{id}/supervisor/conversation", get(history::get_supervisor_conversation)) - // Contract daemon endpoints (for tasks to interact with contracts) - .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status)) - .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist)) - .route("/contracts/{id}/daemon/goals", get(contract_daemon::get_contract_goals)) - .route("/contracts/{id}/daemon/report", post(contract_daemon::post_progress_report)) - .route("/contracts/{id}/daemon/suggest-action", post(contract_daemon::get_suggest_action)) - .route("/contracts/{id}/daemon/completion-action", post(contract_daemon::get_completion_action)) - .route( - "/contracts/{id}/daemon/files", - get(contract_daemon::list_contract_files).post(contract_daemon::create_contract_file), - ) - .route( - "/contracts/{id}/daemon/files/{file_id}", - get(contract_daemon::get_contract_file).put(contract_daemon::update_contract_file), - ) - // Contract repository endpoints - .route("/contracts/{id}/repositories/remote", post(contracts::add_remote_repository)) - .route("/contracts/{id}/repositories/local", post(contracts::add_local_repository)) - .route("/contracts/{id}/repositories/managed", post(contracts::create_managed_repository)) - .route( - "/contracts/{id}/repositories/{repo_id}", - axum::routing::delete(contracts::delete_repository), - ) - .route( - "/contracts/{id}/repositories/{repo_id}/primary", - axum::routing::put(contracts::set_repository_primary), - ) - // Contract task association endpoints - .route( - "/contracts/{id}/tasks/{task_id}", - post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), - ) + // Contract endpoints removed in Phase 5. The contracts subsystem + // has been folded into directives — see Phase 5 in the unified + // surface plan. Routes are gone; handler files were deleted. // Directive endpoints .route( "/directives", diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 7a4b004..51a1c0d 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -31,7 +31,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -92,27 +92,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::delete_account_handler, users::get_user_settings_handler, users::update_user_settings_handler, - // Contract endpoints - contracts::list_contracts, - contracts::get_contract, - contracts::create_contract, - contracts::update_contract, - contracts::delete_contract, - contracts::change_phase, - contracts::get_events, - contracts::add_remote_repository, - contracts::add_local_repository, - contracts::create_managed_repository, - contracts::delete_repository, - contracts::set_repository_primary, - contracts::add_task_to_contract, - contracts::remove_task_from_contract, - // Contract chat endpoints - contract_chat::contract_chat_handler, - contract_chat::get_contract_chat_history, - contract_chat::clear_contract_chat_history, - // Contract discuss endpoint - contract_discuss::discuss_contract_handler, + // Contract endpoints removed in Phase 5. // Directive endpoints directives::list_directives, directives::create_directive, @@ -182,15 +162,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage MeshChatConversation, MeshChatMessageRecord, MeshChatHistoryResponse, - // Contract chat schemas - ContractChatMessageRecord, - ContractChatHistoryResponse, - // Contract discuss schemas - contract_discuss::ChatMessage, - contract_discuss::DiscussContractRequest, - contract_discuss::DiscussContractResponse, - contract_discuss::ToolCallInfo, - contract_discuss::CreatedContractInfo, + // Contract chat / discuss schemas removed in Phase 5. // Merge schemas BranchInfo, BranchListResponse, |
