diff options
25 files changed, 1278 insertions, 162 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 9556458..17013ac 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -14,7 +14,7 @@ const NAV_LINKS: NavLink[] = [ { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Orders", href: "/orders", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, - { label: "Mesh", href: "/mesh", requiresAuth: true }, + { label: "Exec", href: "/exec", requiresAuth: true }, { label: "Daemons", href: "/daemons", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, ]; diff --git a/makima/frontend/src/components/PhaseConfirmationNotification.tsx b/makima/frontend/src/components/PhaseConfirmationNotification.tsx index 516211f..2681fdc 100644 --- a/makima/frontend/src/components/PhaseConfirmationNotification.tsx +++ b/makima/frontend/src/components/PhaseConfirmationNotification.tsx @@ -86,7 +86,7 @@ export function PhaseConfirmationToast() { const handleGoToTask = (question: PendingQuestion) => { dismissNotification(question.questionId); - navigate(`/mesh/${question.taskId}`); + navigate(`/exec/${question.taskId}`); }; return ( diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx index b1cbacc..e62638c 100644 --- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx +++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx @@ -16,7 +16,7 @@ export function SupervisorQuestionNotification() { const handleGoToTask = (questionId: string, taskId: string) => { dismissNotification(questionId); - navigate(`/mesh/${taskId}`); + navigate(`/exec/${taskId}`); }; return ( diff --git a/makima/frontend/src/components/contracts/CommandModePanel.tsx b/makima/frontend/src/components/contracts/CommandModePanel.tsx index 832d5ec..b39b309 100644 --- a/makima/frontend/src/components/contracts/CommandModePanel.tsx +++ b/makima/frontend/src/components/contracts/CommandModePanel.tsx @@ -65,7 +65,7 @@ export function CommandModePanel({ contract, onUpdate }: CommandModePanelProps) const handleGoToSupervisor = useCallback(() => { if (supervisorStatus.supervisorTaskId) { - navigate(`/mesh/${supervisorStatus.supervisorTaskId}`); + navigate(`/exec/${supervisorStatus.supervisorTaskId}`); } }, [supervisorStatus.supervisorTaskId, navigate]); const config = statusConfig[supervisorStatus.status]; diff --git a/makima/frontend/src/components/directives/DirectiveDAG.tsx b/makima/frontend/src/components/directives/DirectiveDAG.tsx index 142df41..f225356 100644 --- a/makima/frontend/src/components/directives/DirectiveDAG.tsx +++ b/makima/frontend/src/components/directives/DirectiveDAG.tsx @@ -146,7 +146,7 @@ function SpecializedStepNode({ step }: { step: SpecializedStep }) { {step.name} </span> <a - href={`/mesh/${step.taskId}`} + href={`/exec/${step.taskId}`} className="text-[9px] font-mono text-[#556677] hover:text-white underline" > View task diff --git a/makima/frontend/src/components/directives/OrchestratorStepNode.tsx b/makima/frontend/src/components/directives/OrchestratorStepNode.tsx index 9c8e95e..3fccb4b 100644 --- a/makima/frontend/src/components/directives/OrchestratorStepNode.tsx +++ b/makima/frontend/src/components/directives/OrchestratorStepNode.tsx @@ -151,7 +151,7 @@ export function OrchestratorStepNode({ {/* Task link */} <a - href={`/mesh/${taskId}`} + href={`/exec/${taskId}`} className={`text-[9px] font-mono text-[#556677] hover:${colors.text} underline block`} > {status === "running" ? "View running task" : "View task"} diff --git a/makima/frontend/src/components/directives/StepNode.tsx b/makima/frontend/src/components/directives/StepNode.tsx index 2844b4a..775b898 100644 --- a/makima/frontend/src/components/directives/StepNode.tsx +++ b/makima/frontend/src/components/directives/StepNode.tsx @@ -28,10 +28,11 @@ interface StepNodeProps { export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { const colors = STATUS_COLORS[step.status] || STATUS_COLORS.pending; const label = STATUS_LABELS[step.status] || step.status.toUpperCase(); + const isContractBacked = !!step.contractType; return ( <div - className={`${colors.bg} ${colors.border} border rounded px-3 py-2 min-w-[160px] max-w-[220px]`} + className={`${colors.bg} ${isContractBacked ? "border-2 border-dashed" : "border"} ${colors.border} rounded px-3 py-2 min-w-[160px] max-w-[220px]`} > <div className="flex items-center justify-between gap-2 mb-1"> <span className="text-[11px] font-mono text-white truncate font-medium"> @@ -41,14 +42,32 @@ export function StepNode({ step, onComplete, onFail, onSkip }: StepNodeProps) { {label} </span> </div> + {isContractBacked && ( + <div className="flex items-center gap-1 mb-1"> + <span className="text-[8px] font-mono text-violet-400 bg-violet-400/10 border border-violet-400/20 rounded px-1 py-0.5 uppercase tracking-wide"> + CONTRACT + </span> + <span className="text-[8px] font-mono text-violet-300/60"> + {step.contractType} + </span> + </div> + )} {step.description && ( <p className="text-[10px] text-[#7788aa] font-mono truncate mb-1"> {step.description} </p> )} - {step.taskId && ( + {step.contractId && ( + <a + href={`/contracts/${step.contractId}`} + className="text-[9px] font-mono text-violet-400/60 hover:text-violet-300 underline block mb-1" + > + {step.status === "running" ? "Contract running..." : "View contract"} + </a> + )} + {step.taskId && !step.contractId && ( <a - href={`/mesh/${step.taskId}`} + href={`/exec/${step.taskId}`} className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc] underline block mb-1" > {step.status === "running" ? "Auto-executing..." : "View task"} diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index 2db4250..1a376ad 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -309,7 +309,21 @@ function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: Ou ); case "auth_required": - return <AuthRequiredEntry entry={entry} />; + return ( + <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-amber-400 font-semibold mb-1"> + <span>⚠</span> + <span>Authentication Expired{entry.toolInput?.hostname ? ` (${entry.toolInput.hostname})` : ""}</span> + </div> + <p className="text-amber-200/80 text-sm"> + The daemon's OAuth token has expired. Go to the{" "} + <a href="/daemons" className="text-[#75aafc] hover:text-[#9bc3ff] underline"> + Daemons page + </a>{" "} + to reauthorize, then retry this task. + </p> + </div> + ); case "supervisor_question": return ( @@ -501,98 +515,8 @@ function SupervisorQuestionEntry({ ); } -function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { - const [authCode, setAuthCode] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [submitted, setSubmitted] = useState(false); - const [error, setError] = useState<string | null>(null); - - const loginUrl = entry.toolInput?.loginUrl as string | undefined; - const hostname = entry.toolInput?.hostname as string | undefined; - // Get taskId from entry or fallback to toolInput (for robustness) - const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!authCode.trim() || !taskId) return; - - setSubmitting(true); - setError(null); - - try { - // Send the auth code to the task via the message endpoint - await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`); - setSubmitted(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to submit code"); - } finally { - setSubmitting(false); - } - }; - - if (submitted) { - return ( - <div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2"> - <div className="flex items-center gap-2 text-green-400 font-semibold"> - <span>✓</span> - <span>Authentication code submitted</span> - </div> - <p className="text-green-200/80 text-sm mt-1"> - Waiting for authentication to complete... - </p> - </div> - ); - } - - return ( - <div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2"> - <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2"> - <span>🔐</span> - <span>Authentication Required{hostname ? ` (${hostname})` : ""}</span> - </div> - <p className="text-amber-200/80 text-sm mb-3"> - The daemon's OAuth token has expired. Click the button to login, then paste the code below: - </p> - - <div className="flex flex-col gap-3"> - {loginUrl ? ( - <a - href={loginUrl} - target="_blank" - rel="noopener noreferrer" - className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center" - > - 1. Login to Claude - </a> - ) : ( - <p className="text-red-400 text-sm">Login URL not available</p> - )} - - <form onSubmit={handleSubmit} className="flex gap-2"> - <input - type="text" - value={authCode} - onChange={(e) => setAuthCode(e.target.value)} - placeholder="2. Paste authentication code here" - className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" - disabled={submitting} - /> - <button - type="submit" - disabled={submitting || !authCode.trim()} - className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors" - > - {submitting ? "..." : "Submit"} - </button> - </form> - - {error && ( - <p className="text-red-400 text-sm">{error}</p> - )} - </div> - </div> - ); -} +// AuthRequiredEntry has been removed - auth flow is now on the Daemons page. +// The "auth_required" case in OutputEntryRenderer shows a redirect message instead. /** Entry for phase transition confirmations */ function PhaseConfirmationEntry({ diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 458b69d..155c716 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -1258,6 +1258,76 @@ export function getDaemonDownloadUrl(platform: string): string { } // ============================================================================= +// Daemon Reauthorization +// ============================================================================= + +/** Response from the trigger daemon reauth endpoint */ +export interface TriggerReauthResponse { + success: boolean; + daemonId: string; + requestId: string; +} + +/** Response from the reauth status polling endpoint */ +export interface ReauthStatusResponse { + status: string; // "pending" | "url_ready" | "completed" | "failed" + loginUrl?: string; + error?: string; +} + +/** + * Trigger OAuth re-authentication on a daemon. + * Sends a reauth command to the daemon, which will spawn `claude setup-token` + * and return the OAuth login URL. + */ +export async function triggerDaemonReauth(daemonId: string): Promise<TriggerReauthResponse> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${daemonId}/reauth`, { + method: "POST", + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to trigger reauth: ${res.statusText}`); + } + return res.json(); +} + +/** + * Submit an OAuth auth code to a daemon's pending reauth flow. + */ +export async function submitDaemonAuthCode( + daemonId: string, + code: string, + requestId: string, +): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${daemonId}/reauth/code`, { + method: "POST", + body: JSON.stringify({ code, requestId }), + }); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to submit auth code: ${res.statusText}`); + } +} + +/** + * Get the status of a daemon reauth request. + * Used for polling to track reauth flow progress. + */ +export async function getDaemonReauthStatus( + daemonId: string, + requestId: string, +): Promise<ReauthStatusResponse> { + const res = await authFetch( + `${API_BASE}/api/v1/mesh/daemons/${daemonId}/reauth/${requestId}/status`, + ); + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData.message || `Failed to get reauth status: ${res.statusText}`); + } + return res.json(); +} + +// ============================================================================= // Mesh Chat Types for Task Orchestration // ============================================================================= @@ -3081,6 +3151,10 @@ export interface DirectiveStep { dependsOn: string[]; status: StepStatus; taskId: string | null; + /** Contract ID for contract-backed steps */ + contractId?: string | null; + /** Contract type (e.g. "simple", "specification", "execute") for contract-backed steps */ + contractType?: string | null; orderIndex: number; generation: number; startedAt: string | null; diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 32c05ba..4f7c525 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -98,7 +98,7 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/mesh" + path="/exec" element={ <ProtectedRoute> <MeshPage /> @@ -106,7 +106,7 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/mesh/:id" + path="/exec/:id" element={ <ProtectedRoute> <MeshPage /> diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index b85d667..ce9ceca 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -448,7 +448,7 @@ function ContractsPageContent() { const handleTaskSelect = useCallback( (taskId: string) => { - navigate(`/mesh/${taskId}`); + navigate(`/exec/${taskId}`); }, [navigate] ); @@ -469,7 +469,7 @@ function ContractsPageContent() { const refreshed = await fetchContract(contractDetail.id); setContractDetail(refreshed); // Navigate to the new task - navigate(`/mesh/${task.id}`); + navigate(`/exec/${task.id}`); } catch (e) { console.error("Failed to create task:", e); alert(e instanceof Error ? e.message : "Failed to create task"); @@ -515,7 +515,7 @@ function ContractsPageContent() { const handleContextGoToSupervisor = useCallback( (contract: ContractSummary) => { if (contract.supervisorTaskId) { - navigate(`/mesh/${contract.supervisorTaskId}`); + navigate(`/exec/${contract.supervisorTaskId}`); } }, [navigate] diff --git a/makima/frontend/src/routes/daemons.tsx b/makima/frontend/src/routes/daemons.tsx index 0f55190..ca167fe 100644 --- a/makima/frontend/src/routes/daemons.tsx +++ b/makima/frontend/src/routes/daemons.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useAuth } from "../contexts/AuthContext"; import { useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { listDaemons, restartDaemon, + triggerDaemonReauth, + submitDaemonAuthCode, + getDaemonReauthStatus, type Daemon, type DaemonListResponse, } from "../lib/api"; @@ -34,6 +37,333 @@ function ErrorAlert({ children }: { children: React.ReactNode }) { } // ============================================================================= +// Reauth Modal Component +// ============================================================================= + +type ReauthState = + | { phase: "initiating" } + | { phase: "url_ready"; loginUrl: string; requestId: string } + | { phase: "submitting"; requestId: string } + | { phase: "success" } + | { phase: "error"; message: string }; + +function ReauthModal({ + daemon, + onClose, +}: { + daemon: Daemon; + onClose: () => void; +}) { + const [state, setState] = useState<ReauthState>({ phase: "initiating" }); + const [authCode, setAuthCode] = useState(""); + const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null); + + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + }, []); + + // Trigger reauth on mount + useEffect(() => { + let cancelled = false; + const trigger = async () => { + try { + const res = await triggerDaemonReauth(daemon.id); + if (cancelled) return; + + // Start polling for status + const requestId = res.requestId; + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus(daemon.id, requestId); + if (cancelled) return; + + if (status.status === "url_ready" && status.loginUrl) { + setState({ + phase: "url_ready", + loginUrl: status.loginUrl, + requestId, + }); + // Stop polling once we have the URL + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Reauth failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "completed") { + setState({ phase: "success" }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Polling errors are non-fatal, keep trying + } + }, 2000); + } catch (err) { + if (cancelled) return; + setState({ + phase: "error", + message: + err instanceof Error ? err.message : "Failed to trigger reauth", + }); + } + }; + trigger(); + return () => { + cancelled = true; + }; + }, [daemon.id]); + + const handleSubmitCode = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!authCode.trim() || state.phase !== "url_ready") return; + + const requestId = state.requestId; + setState({ phase: "submitting", requestId }); + + try { + await submitDaemonAuthCode(daemon.id, authCode.trim(), requestId); + + // Poll for completion + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus( + daemon.id, + requestId, + ); + if (status.status === "completed") { + setState({ phase: "success" }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Auth code submission failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Keep polling + } + }, 2000); + + // Also set a timeout so we don't poll forever + setTimeout(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + // If still submitting after 30s, assume success (setup-token completed) + setState((prev) => + prev.phase === "submitting" ? { phase: "success" } : prev, + ); + }, 30000); + } catch (err) { + setState({ + phase: "error", + message: + err instanceof Error + ? err.message + : "Failed to submit auth code", + }); + } + }, + [authCode, daemon.id, state], + ); + + const handleRetry = useCallback(() => { + setAuthCode(""); + setState({ phase: "initiating" }); + // Re-trigger will happen via the useEffect dependency change + // We need to manually trigger since daemon.id hasn't changed + const trigger = async () => { + try { + const res = await triggerDaemonReauth(daemon.id); + const requestId = res.requestId; + pollingRef.current = setInterval(async () => { + try { + const status = await getDaemonReauthStatus( + daemon.id, + requestId, + ); + if (status.status === "url_ready" && status.loginUrl) { + setState({ + phase: "url_ready", + loginUrl: status.loginUrl, + requestId, + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } else if (status.status === "failed") { + setState({ + phase: "error", + message: status.error || "Reauth failed", + }); + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + } + } catch { + // Keep polling + } + }, 2000); + } catch (err) { + setState({ + phase: "error", + message: + err instanceof Error + ? err.message + : "Failed to trigger reauth", + }); + } + }; + trigger(); + }, [daemon.id]); + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> + <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4 shadow-2xl"> + {/* Header */} + <div className="flex items-center justify-between mb-4"> + <h3 className="text-xs font-mono uppercase tracking-wide text-[#9bc3ff]"> + Reauthorize Daemon + </h3> + <button + onClick={onClose} + className="text-[#7788aa] hover:text-[#9bc3ff] text-sm font-mono" + > + ✕ + </button> + </div> + <p className="text-[10px] font-mono text-[#7788aa] mb-4"> + {daemon.hostname || "Unknown Host"} + </p> + + {/* Initiating */} + {state.phase === "initiating" && ( + <div className="flex items-center gap-2 py-4"> + <div className="w-3 h-3 border border-[#75aafc] border-t-transparent rounded-full animate-spin" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Initiating reauthorization... + </span> + </div> + )} + + {/* URL Ready */} + {state.phase === "url_ready" && ( + <div className="space-y-3"> + <p className="text-[10px] font-mono text-[#7788aa]"> + Click the button below to open the OAuth login page, then paste the code: + </p> + <a + href={state.loginUrl} + target="_blank" + rel="noopener noreferrer" + className="block text-center bg-amber-500 hover:bg-amber-400 text-black font-mono text-xs font-medium px-4 py-2 transition-colors" + > + 1. Login to Claude + </a> + <form onSubmit={handleSubmitCode} className="flex gap-2"> + <input + type="text" + value={authCode} + onChange={(e) => setAuthCode(e.target.value)} + placeholder="2. Paste authentication code" + className="flex-1 bg-[#0a1525] border border-amber-500/30 px-3 py-2 text-xs font-mono text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400" + /> + <button + type="submit" + disabled={!authCode.trim()} + className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-mono text-xs font-medium px-4 py-2 transition-colors" + > + Submit + </button> + </form> + </div> + )} + + {/* Submitting */} + {state.phase === "submitting" && ( + <div className="flex items-center gap-2 py-4"> + <div className="w-3 h-3 border border-amber-400 border-t-transparent rounded-full animate-spin" /> + <span className="text-[10px] font-mono text-[#7788aa]"> + Submitting auth code... + </span> + </div> + )} + + {/* Success */} + {state.phase === "success" && ( + <div className="py-4"> + <div className="flex items-center gap-2 text-green-400 mb-2"> + <span className="text-sm">✓</span> + <span className="text-xs font-mono font-medium"> + Authentication successful + </span> + </div> + <p className="text-[10px] font-mono text-[#7788aa] mb-3"> + The daemon's OAuth token has been refreshed. Tasks can now run normally. + </p> + <button + onClick={onClose} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" + > + Close + </button> + </div> + )} + + {/* Error */} + {state.phase === "error" && ( + <div className="py-4"> + <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-3 font-mono text-xs"> + {state.message} + </div> + <div className="flex gap-2"> + <button + onClick={handleRetry} + className="text-[10px] font-mono text-amber-400 hover:text-amber-300 px-2 py-1 border border-amber-700/50 bg-amber-900/20" + > + Retry + </button> + <button + onClick={onClose} + className="text-[10px] font-mono text-[#7788aa] hover:text-[#9bc3ff] px-2 py-1" + > + Close + </button> + </div> + </div> + )} + </div> + </div> + ); +} + +// ============================================================================= // Daemons Page // ============================================================================= @@ -47,6 +377,7 @@ export default function DaemonsPage() { const [daemonsError, setDaemonsError] = useState<string | null>(null); const [restartingDaemonId, setRestartingDaemonId] = useState<string | null>(null); const [restartConfirmDaemonId, setRestartConfirmDaemonId] = useState<string | null>(null); + const [reauthDaemon, setReauthDaemon] = useState<Daemon | null>(null); // Redirect if not authenticated useEffect(() => { @@ -292,7 +623,7 @@ export default function DaemonsPage() { </div> )} </div> - {/* Restart Section */} + {/* Actions Section */} {daemon.status === "connected" && ( <div className="mt-3 pt-2 border-t border-[rgba(117,170,252,0.1)]"> {restartConfirmDaemonId === daemon.id ? ( @@ -318,12 +649,20 @@ export default function DaemonsPage() { </div> </div> ) : ( - <button - onClick={() => setRestartConfirmDaemonId(daemon.id)} - className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" - > - ⟳ Restart Daemon - </button> + <div className="flex items-center gap-3"> + <button + onClick={() => setReauthDaemon(daemon)} + className="text-[10px] font-mono text-amber-400 hover:text-amber-300" + > + 🔑 Reauthorize + </button> + <button + onClick={() => setRestartConfirmDaemonId(daemon.id)} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff]" + > + ⟳ Restart Daemon + </button> + </div> )} </div> )} @@ -335,6 +674,14 @@ export default function DaemonsPage() { </div> </div> </main> + + {/* Reauth Modal */} + {reauthDaemon && ( + <ReauthModal + daemon={reauthDaemon} + onClose={() => setReauthDaemon(null)} + /> + )} </div> ); } diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 6cfb3ca..b232aa0 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -839,7 +839,7 @@ function FilesPageContent() { <div className="flex gap-2"> <button onClick={() => { - navigate(`/mesh/${createdTask.id}`); + navigate(`/exec/${createdTask.id}`); setCreatedTask(null); }} className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors" diff --git a/makima/frontend/src/routes/login.tsx b/makima/frontend/src/routes/login.tsx index 63b3af3..0725a2d 100644 --- a/makima/frontend/src/routes/login.tsx +++ b/makima/frontend/src/routes/login.tsx @@ -18,7 +18,7 @@ export default function LoginPage() { // Redirect if already authenticated if (isAuthenticated && isAuthConfigured) { - navigate("/mesh"); + navigate("/exec"); return null; } @@ -34,7 +34,7 @@ export default function LoginPage() { if (error) { setError(error.message); } else { - navigate("/mesh"); + navigate("/exec"); } } else if (mode === "signup") { const { error } = await signUp(email, password); diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 1d1db84..67129f9 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -191,14 +191,6 @@ export default function MeshPage() { // Only process output for the task we're currently viewing if (event.taskId === activeOutputTaskId) { setTaskOutputEntries((prev) => { - // For auth_required, only allow one per task (replace existing) - if (event.messageType === "auth_required") { - const hasExisting = prev.some(e => e.messageType === "auth_required"); - if (hasExisting) { - return prev; // Skip duplicate auth_required - } - } - // Deduplicate by checking if last entry is identical // This prevents duplicates from React StrictMode or WebSocket reconnects const lastEntry = prev[prev.length - 1]; @@ -387,7 +379,7 @@ export default function MeshPage() { const handleSelectTask = useCallback( (taskId: string) => { - navigate(`/mesh/${taskId}`); + navigate(`/exec/${taskId}`); }, [navigate] ); @@ -395,9 +387,9 @@ export default function MeshPage() { const handleBack = useCallback(() => { // If viewing a subtask, go back to parent if (taskDetail?.parentTaskId) { - navigate(`/mesh/${taskDetail.parentTaskId}`); + navigate(`/exec/${taskDetail.parentTaskId}`); } else { - navigate("/mesh"); + navigate("/exec"); } }, [navigate, taskDetail]); @@ -408,9 +400,9 @@ export default function MeshPage() { if (success && id === taskId) { // If deleting current task, go back if (taskDetail?.parentTaskId) { - navigate(`/mesh/${taskDetail.parentTaskId}`); + navigate(`/exec/${taskDetail.parentTaskId}`); } else { - navigate("/mesh"); + navigate("/exec"); } } } @@ -523,7 +515,7 @@ export default function MeshPage() { }); console.log(`[Mesh] Task branched, new task ID: ${result.task.id}`); // Navigate to the new branched task - navigate(`/mesh/${result.task.id}`); + navigate(`/exec/${result.task.id}`); } catch (e) { console.error("Failed to branch task:", e); throw e; // Re-throw so the modal can display the error @@ -617,7 +609,7 @@ export default function MeshPage() { targetRepoPath: targetPath || undefined, }); if (newTask) { - navigate(`/mesh/${newTask.id}`); + navigate(`/exec/${newTask.id}`); } } finally { setCreating(false); diff --git a/makima/migrations/20260302000000_add_contract_to_directive_steps.sql b/makima/migrations/20260302000000_add_contract_to_directive_steps.sql new file mode 100644 index 0000000..f018aa4 --- /dev/null +++ b/makima/migrations/20260302000000_add_contract_to_directive_steps.sql @@ -0,0 +1,4 @@ +-- Add contract_id to directive_steps for contract-backed execution +ALTER TABLE directive_steps ADD COLUMN contract_id UUID REFERENCES contracts(id); +-- Add contract_type to specify what kind of contract to create +ALTER TABLE directive_steps ADD COLUMN contract_type VARCHAR(32); diff --git a/makima/src/daemon/task/manager.rs b/makima/src/daemon/task/manager.rs index b382507..addcd71 100644 --- a/makima/src/daemon/task/manager.rs +++ b/makima/src/daemon/task/manager.rs @@ -1886,6 +1886,66 @@ impl TaskManager { tracing::info!("Daemon restart: exiting process with code 42 (restart requested)"); std::process::exit(42); } + DaemonCommand::TriggerReauth { request_id } => { + tracing::info!(request_id = %request_id, "Received reauth trigger command from server"); + let claude_command = self.process_manager.claude_command().to_string(); + let ws_tx = self.ws_tx.clone(); + + // Spawn in a task so it doesn't block command handling + tokio::spawn(async move { + match get_oauth_login_url(&claude_command).await { + Some(login_url) => { + tracing::info!(request_id = %request_id, login_url = %login_url, "Got OAuth login URL for reauth"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "url_ready".to_string(), + login_url: Some(login_url), + error: None, + }; + let _ = ws_tx.send(msg).await; + } + None => { + tracing::error!(request_id = %request_id, "Failed to get OAuth login URL for reauth"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "failed".to_string(), + login_url: None, + error: Some("Failed to get OAuth login URL from setup-token".to_string()), + }; + let _ = ws_tx.send(msg).await; + } + } + }); + } + DaemonCommand::SubmitAuthCode { request_id, code } => { + tracing::info!(request_id = %request_id, "Received auth code submission from server"); + let ws_tx = self.ws_tx.clone(); + + if send_auth_code(&code) { + tracing::info!(request_id = %request_id, "Auth code forwarded to setup-token for reauth"); + // Wait a short time then report completion + // (the setup-token process takes a moment to complete) + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "completed".to_string(), + login_url: None, + error: None, + }; + let _ = ws_tx.send(msg).await; + }); + } else { + tracing::warn!(request_id = %request_id, "No pending auth flow to receive code for reauth"); + let msg = DaemonMessage::ReauthStatus { + request_id, + status: "failed".to_string(), + login_url: None, + error: Some("No pending auth flow to receive the code. Try triggering reauth again.".to_string()), + }; + let _ = self.ws_tx.send(msg).await; + } + } DaemonCommand::ApplyPatchToWorktree { target_task_id, source_task_id, @@ -5260,32 +5320,19 @@ impl TaskManagerInner { break; } - // Detect OAuth token expiration and trigger remote login flow + // Detect OAuth token expiration - log warning and let the task fail normally. + // Users can reauthorize via the Daemons page instead. if !auth_error_handled && is_oauth_auth_error(&content_for_auth_check, json_type_for_auth_check.as_deref(), is_stdout_for_auth_check) { auth_error_handled = true; - tracing::warn!(task_id = %task_id, "OAuth authentication error detected, initiating remote login flow"); - - // Spawn claude setup-token to get login URL - if let Some(login_url) = get_oauth_login_url(&claude_command).await { - tracing::info!(task_id = %task_id, login_url = %login_url, "Got OAuth login URL"); - let auth_msg = DaemonMessage::AuthenticationRequired { - task_id: Some(task_id), - login_url, - hostname: daemon_hostname.clone(), - }; - if ws_tx.send(auth_msg).await.is_err() { - tracing::warn!(task_id = %task_id, "Failed to send auth required message"); - } - } else { - tracing::error!(task_id = %task_id, "Failed to get OAuth login URL from setup-token"); - let fallback_msg = DaemonMessage::task_output( - task_id, - format!("Authentication required on daemon{}. Please run 'claude /login' on the daemon machine.\n", - daemon_hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default()), - false, - ); - let _ = ws_tx.send(fallback_msg).await; - } + tracing::warn!(task_id = %task_id, "OAuth authentication error detected - task will fail. Reauthorize via Daemons page."); + + let error_msg = DaemonMessage::task_output( + task_id, + format!("⚠ Authentication expired on daemon{}. Go to Daemons page to reauthorize, then retry this task.\n", + daemon_hostname.as_ref().map(|h| format!(" ({})", h)).unwrap_or_default()), + false, + ); + let _ = ws_tx.send(error_msg).await; } } None => { diff --git a/makima/src/daemon/ws/protocol.rs b/makima/src/daemon/ws/protocol.rs index 834e139..bed5ffd 100644 --- a/makima/src/daemon/ws/protocol.rs +++ b/makima/src/daemon/ws/protocol.rs @@ -129,6 +129,19 @@ pub enum DaemonMessage { hostname: Option<String>, }, + /// Reauth status update (response to TriggerReauth/SubmitAuthCode commands). + ReauthStatus { + #[serde(rename = "requestId")] + request_id: Uuid, + /// Status: "url_ready", "completed", "failed" + status: String, + /// OAuth login URL (present when status is "url_ready") + #[serde(rename = "loginUrl")] + login_url: Option<String>, + /// Error message (present when status is "failed") + error: Option<String>, + }, + // ========================================================================= // Merge Response Messages (sent by daemon after processing merge commands) // ========================================================================= @@ -787,6 +800,19 @@ pub enum DaemonCommand { /// Restart the daemon process. RestartDaemon, + /// Trigger OAuth re-authentication on this daemon. + TriggerReauth { + #[serde(rename = "requestId")] + request_id: Uuid, + }, + + /// Submit auth code for pending reauth. + SubmitAuthCode { + #[serde(rename = "requestId")] + request_id: Uuid, + code: String, + }, + /// Apply a patch to a task's worktree (for cross-daemon merge). /// Sent by server when routing MergePatchToSupervisor to the supervisor's daemon. ApplyPatchToWorktree { diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 6b77563..6292e7b 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2746,6 +2746,11 @@ pub struct DirectiveStep { /// Status: pending, ready, running, completed, failed, skipped pub status: String, pub task_id: Option<Uuid>, + /// Optional contract ID for contract-backed execution. + pub contract_id: Option<Uuid>, + /// Optional contract type (e.g. "simple", "specification", "execute"). + /// When set, the orchestrator creates a contract instead of a standalone task. + pub contract_type: Option<String>, pub order_index: i32, pub generation: i32, pub started_at: Option<DateTime<Utc>>, @@ -2871,6 +2876,10 @@ pub struct CreateDirectiveStepRequest { /// Optional order ID to auto-link this step to an order. #[serde(default)] pub order_id: Option<Uuid>, + /// Optional: create a contract for this step instead of a standalone task. + /// Valid values: "simple", "specification", "execute" + #[serde(default)] + pub contract_type: Option<String>, } /// Request to update a directive step. diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 8d7a70c..1af22f6 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5402,10 +5402,11 @@ pub async fn create_directive_step( ) -> Result<DirectiveStep, sqlx::Error> { let generation = req.generation.unwrap_or(1); let order_id = req.order_id; + let contract_type = req.contract_type.clone(); let step = sqlx::query_as::<_, DirectiveStep>( r#" - INSERT INTO directive_steps (directive_id, name, description, task_plan, depends_on, order_index, generation) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO directive_steps (directive_id, name, description, task_plan, depends_on, order_index, generation, contract_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * "#, ) @@ -5416,6 +5417,7 @@ pub async fn create_directive_step( .bind(&req.depends_on) .bind(req.order_index) .bind(generation) + .bind(&contract_type) .fetch_one(pool) .await?; @@ -5727,6 +5729,8 @@ pub struct StepForDispatch { pub order_index: i32, pub generation: i32, pub depends_on: Vec<Uuid>, + /// Optional contract type — when set, orchestrator creates a contract instead of a task. + pub contract_type: Option<String>, // Directive fields pub owner_id: Uuid, pub directive_title: String, @@ -5751,6 +5755,7 @@ pub async fn get_ready_steps_for_dispatch( ds.order_index, ds.generation, ds.depends_on, + ds.contract_type, d.owner_id, d.title AS directive_title, d.repository_url, @@ -5760,6 +5765,7 @@ pub async fn get_ready_steps_for_dispatch( JOIN directives d ON d.id = ds.directive_id WHERE ds.status = 'ready' AND ds.task_id IS NULL + AND ds.contract_id IS NULL AND d.status = 'active' ORDER BY ds.order_index "#, @@ -5831,6 +5837,39 @@ pub async fn get_running_steps_with_tasks( JOIN tasks t ON t.id = ds.task_id WHERE ds.status = 'running' AND ds.task_id IS NOT NULL + AND ds.contract_id IS NULL + "#, + ) + .fetch_all(pool) + .await +} + +/// A running step backed by a contract, joined with the contract's current status. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct RunningStepWithContract { + pub step_id: Uuid, + pub directive_id: Uuid, + pub contract_id: Uuid, + pub contract_status: String, + pub contract_phase: String, +} + +/// Get running steps that are backed by contracts (for contract-based monitoring). +pub async fn get_running_steps_with_contracts( + pool: &PgPool, +) -> Result<Vec<RunningStepWithContract>, sqlx::Error> { + sqlx::query_as::<_, RunningStepWithContract>( + r#" + SELECT + ds.id AS step_id, + ds.directive_id, + ds.contract_id AS "contract_id!", + c.status AS contract_status, + c.phase AS contract_phase + FROM directive_steps ds + JOIN contracts c ON c.id = ds.contract_id + WHERE ds.status = 'running' + AND ds.contract_id IS NOT NULL "#, ) .fetch_all(pool) @@ -5995,6 +6034,26 @@ pub async fn link_task_to_step( Ok(()) } +/// Link a contract to a directive step. +pub async fn link_contract_to_step( + pool: &PgPool, + step_id: Uuid, + contract_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE directive_steps + SET contract_id = $1 + WHERE id = $2 + "#, + ) + .bind(contract_id) + .bind(step_id) + .execute(pool) + .await?; + Ok(()) +} + /// Set a step to 'running' status (after its task has been dispatched). pub async fn set_step_running( pool: &PgPool, diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 98690bb..155cfad 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use base64::Engine; -use crate::db::models::{CreateTaskRequest, UpdateTaskRequest}; +use crate::db::models::{CreateContractRequest, CreateTaskRequest, UpdateContractRequest, UpdateTaskRequest}; use crate::db::repository; use crate::server::state::{DaemonCommand, SharedState}; @@ -86,6 +86,42 @@ 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 + if step.contract_type.is_some() { + tracing::info!( + 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" + ); + + 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!( step_id = %step.step_id, directive_id = %step.directive_id, @@ -218,7 +254,70 @@ impl DirectiveOrchestrator { /// Phase 3: Monitor running steps and orchestrator tasks. async fn phase_monitoring(&self) -> Result<(), anyhow::Error> { - // Check running steps + // Check contract-backed running steps first + let contract_steps = repository::get_running_steps_with_contracts(&self.pool).await?; + + for step in contract_steps { + if let Err(e) = async { + match step.contract_status.as_str() { + "completed" | "archived" => { + tracing::info!( + step_id = %step.step_id, + directive_id = %step.directive_id, + contract_id = %step.contract_id, + contract_status = %step.contract_status, + "Contract-backed step contract completed — updating step to completed" + ); + let update = crate::db::models::UpdateDirectiveStepRequest { + status: Some("completed".to_string()), + ..Default::default() + }; + repository::update_directive_step(&self.pool, step.step_id, update).await?; + + // Mark linked orders as done + if let Ok(linked_orders) = repository::get_orders_by_step_id(&self.pool, step.step_id).await { + for order in linked_orders { + if order.status != "done" && order.status != "archived" { + let order_update = crate::db::models::UpdateOrderRequest { + status: Some("done".to_string()), + ..Default::default() + }; + let _ = repository::update_order(&self.pool, order.owner_id, order.id, order_update).await; + } + } + } + + repository::advance_directive_ready_steps(&self.pool, step.directive_id) + .await?; + repository::check_directive_idle(&self.pool, step.directive_id).await?; + } + "active" => { + // Contract still active — check if the supervisor has failed + // by looking at whether there are any failed tasks with no active tasks remaining + tracing::debug!( + step_id = %step.step_id, + contract_id = %step.contract_id, + contract_phase = %step.contract_phase, + "Contract-backed step still active — monitoring" + ); + } + _ => { + // Unknown status — log and skip + tracing::debug!( + step_id = %step.step_id, + contract_id = %step.contract_id, + contract_status = %step.contract_status, + "Contract-backed step in unexpected status" + ); + } + } + Ok::<(), anyhow::Error>(()) + }.await { + tracing::warn!(step_id = %step.step_id, error = %e, "Error processing contract-backed step — continuing"); + } + } + + // Check task-backed running steps (excludes contract-backed steps) let running = repository::get_running_steps_with_tasks(&self.pool).await?; for step in running { @@ -505,6 +604,142 @@ 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(()) + } + /// Try to dispatch a task to an available daemon. Returns true if dispatched. async fn try_dispatch_task( &self, @@ -1337,6 +1572,10 @@ For each step, define: - orderIndex: Execution phase number. Steps only start after ALL steps with a lower orderIndex complete. Steps with the same orderIndex run in parallel. Use ascending values (0, 1, 2, ...) to create sequential phases. Use dependsOn for fine-grained control within the same phase. +- contractType (OPTIONAL): For large, complex work items, set this to create a full contract instead of a + standalone task. Valid values: "simple" (Plan → Execute), "specification" (Research → Specify → Plan → Execute → Review), + "execute" (Execute only). Only use this for steps that truly need multi-phase orchestration. + Most steps should NOT use this — standalone tasks are the default and preferred for typical work. Submit steps: makima directive add-step "Step Name" --description "..." --task-plan "..." diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index c840676..0e72bdf 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -20,7 +20,7 @@ use crate::db::models::{ use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; -use crate::server::state::{DaemonCommand, SharedState, TaskUpdateNotification}; +use crate::server::state::{DaemonCommand, DaemonReauthStatus, SharedState, TaskUpdateNotification}; // ============================================================================= // Authentication Types @@ -4283,3 +4283,285 @@ pub async fn restart_daemon( }) .into_response() } + +// ============================================================================= +// Daemon Reauthorization +// ============================================================================= + +/// Response from the trigger reauth endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct TriggerReauthResponse { + /// Whether the reauth command was sent successfully. + pub success: bool, + /// The daemon ID that received the reauth command. + #[serde(rename = "daemonId")] + pub daemon_id: Uuid, + /// Unique request ID for tracking this reauth flow. + #[serde(rename = "requestId")] + pub request_id: Uuid, +} + +/// Request body for submitting an auth code. +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct SubmitAuthCodeRequest { + /// The auth code obtained from the OAuth login flow. + pub code: String, + /// The request ID from the trigger reauth response. + #[serde(rename = "requestId")] + pub request_id: Uuid, +} + +/// Response from the submit auth code endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct SubmitAuthCodeResponse { + /// Whether the auth code was sent to the daemon successfully. + pub success: bool, +} + +/// Response from the reauth status polling endpoint. +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ReauthStatusResponse { + /// Current status of the reauth flow: "pending", "url_ready", "completed", "failed" + pub status: String, + /// OAuth login URL (present when status is "url_ready") + #[serde(rename = "loginUrl", skip_serializing_if = "Option::is_none")] + pub login_url: Option<String>, + /// Error message (present when status is "failed") + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +/// Trigger OAuth re-authentication on a daemon. +/// +/// Sends a reauth command to the specified daemon, which will spawn `claude setup-token` +/// and return the OAuth login URL via a status update. +#[utoipa::path( + post, + path = "/api/v1/mesh/daemons/{id}/reauth", + params( + ("id" = Uuid, Path, description = "Daemon ID") + ), + responses( + (status = 200, description = "Reauth command sent", body = TriggerReauthResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Daemon not found or not connected", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn trigger_daemon_reauth( + 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 the daemon exists and belongs to this owner + match repository::get_daemon_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Daemon not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Check if daemon is connected + if !state.is_daemon_connected(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new( + "DAEMON_NOT_CONNECTED", + "Daemon is not currently connected", + )), + ) + .into_response(); + } + + // Generate a unique request ID for this reauth flow + let request_id = Uuid::new_v4(); + + // Initialize the status as "pending" + state.set_daemon_reauth_status(id, request_id, "pending".to_string(), None, None); + + // Send reauth command to daemon + let command = DaemonCommand::TriggerReauth { request_id }; + if let Err(e) = state.send_daemon_command(id, command).await { + tracing::error!("Failed to send reauth command to daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + tracing::info!( + daemon_id = %id, + request_id = %request_id, + owner_id = %auth.owner_id, + "Reauth command sent to daemon" + ); + + Json(TriggerReauthResponse { + success: true, + daemon_id: id, + request_id, + }) + .into_response() +} + +/// Submit an OAuth auth code to a daemon's pending reauth flow. +/// +/// Sends the auth code to the daemon, which will forward it to the `claude setup-token` process. +#[utoipa::path( + post, + path = "/api/v1/mesh/daemons/{id}/reauth/code", + params( + ("id" = Uuid, Path, description = "Daemon ID") + ), + request_body = SubmitAuthCodeRequest, + responses( + (status = 200, description = "Auth code submitted", body = SubmitAuthCodeResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Daemon not found or not connected", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn submit_daemon_auth_code( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(body): Json<SubmitAuthCodeRequest>, +) -> 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 the daemon exists and belongs to this owner + match repository::get_daemon_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Daemon not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Check if daemon is connected + if !state.is_daemon_connected(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new( + "DAEMON_NOT_CONNECTED", + "Daemon is not currently connected", + )), + ) + .into_response(); + } + + // Send auth code command to daemon + let command = DaemonCommand::SubmitAuthCode { + request_id: body.request_id, + code: body.code, + }; + if let Err(e) = state.send_daemon_command(id, command).await { + tracing::error!("Failed to send auth code to daemon {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DAEMON_ERROR", e)), + ) + .into_response(); + } + + tracing::info!( + daemon_id = %id, + request_id = %body.request_id, + owner_id = %auth.owner_id, + "Auth code submitted to daemon" + ); + + Json(SubmitAuthCodeResponse { success: true }).into_response() +} + +/// Get the status of a daemon reauth request. +/// +/// Used by the frontend to poll for reauth status updates. +#[utoipa::path( + get, + path = "/api/v1/mesh/daemons/{id}/reauth/{request_id}/status", + params( + ("id" = Uuid, Path, description = "Daemon ID"), + ("request_id" = Uuid, Path, description = "Reauth request ID") + ), + responses( + (status = 200, description = "Reauth status", body = ReauthStatusResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Reauth request not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Mesh" +)] +pub async fn get_daemon_reauth_status( + State(state): State<SharedState>, + Authenticated(_auth): Authenticated, + Path((id, request_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + match state.get_daemon_reauth_status(id, request_id) { + Some(status) => Json(ReauthStatusResponse { + status: status.status, + login_url: status.login_url, + error: status.error, + }) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Reauth request not found")), + ) + .into_response(), + } +} diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index 743a1ca..30439a4 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -350,6 +350,18 @@ pub enum DaemonMessage { /// Hostname of the daemon requiring auth hostname: Option<String>, }, + /// Reauth status update (response to TriggerReauth/SubmitAuthCode commands) + ReauthStatus { + #[serde(rename = "requestId")] + request_id: Uuid, + /// Status: "url_ready", "completed", "failed" + status: String, + /// OAuth login URL (present when status is "url_ready") + #[serde(rename = "loginUrl")] + login_url: Option<String>, + /// Error message (present when status is "failed") + error: Option<String>, + }, /// Response to RetryCompletionAction command CompletionActionResult { #[serde(rename = "taskId")] @@ -1622,6 +1634,25 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re "OAuth login URL available - user should open this in browser" ); } + Ok(DaemonMessage::ReauthStatus { request_id, status, login_url, error }) => { + tracing::info!( + daemon_id = %daemon_uuid, + request_id = %request_id, + status = %status, + login_url = ?login_url, + error = ?error, + "Daemon reauth status update" + ); + + // Store the reauth status for polling by the frontend + state.set_daemon_reauth_status( + daemon_uuid, + request_id, + status.clone(), + login_url.clone(), + error.clone(), + ); + } Ok(DaemonMessage::DaemonDirectories { working_directory, home_directory, worktrees_directory }) => { tracing::info!( daemon_id = %daemon_uuid, diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index e0f8e7d..b84b90e 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -98,6 +98,9 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/daemons/directories", get(mesh::get_daemon_directories)) .route("/mesh/daemons/{id}", get(mesh::get_daemon)) .route("/mesh/daemons/{id}/restart", post(mesh::restart_daemon)) + .route("/mesh/daemons/{id}/reauth", post(mesh::trigger_daemon_reauth)) + .route("/mesh/daemons/{id}/reauth/code", post(mesh::submit_daemon_auth_code)) + .route("/mesh/daemons/{id}/reauth/{request_id}/status", get(mesh::get_daemon_reauth_status)) // Merge endpoints for orchestrators .route("/mesh/tasks/{id}/branches", get(mesh_merge::list_branches)) .route("/mesh/tasks/{id}/merge/start", post(mesh_merge::merge_start)) diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs index 15fec6b..5c5e24f 100644 --- a/makima/src/server/state.rs +++ b/makima/src/server/state.rs @@ -521,6 +521,19 @@ pub enum DaemonCommand { /// Restart the daemon process RestartDaemon, + /// Trigger OAuth re-authentication on this daemon + TriggerReauth { + #[serde(rename = "requestId")] + request_id: Uuid, + }, + + /// Submit auth code for pending reauth + SubmitAuthCode { + #[serde(rename = "requestId")] + request_id: Uuid, + code: String, + }, + /// Apply a patch to a task's worktree (for cross-daemon merge). /// Sent by server when routing MergePatchToSupervisor to the supervisor's daemon. ApplyPatchToWorktree { @@ -562,6 +575,15 @@ pub struct DaemonConnectionInfo { pub worktrees_directory: Option<String>, } +/// Status of a daemon reauth request (stored in state for polling). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DaemonReauthStatus { + pub request_id: Uuid, + pub status: String, + pub login_url: Option<String>, + pub error: Option<String>, +} + /// Configuration paths for ML models (used for lazy loading). #[derive(Clone)] pub struct ModelConfig { @@ -616,6 +638,8 @@ pub struct AppState { pub pending_worktree_info: DashMap<Uuid, oneshot::Sender<WorktreeInfoResponse>>, /// Lazily-loaded TTS engine (initialized on first Speak connection) pub tts_engine: OnceCell<Box<dyn TtsEngine>>, + /// Daemon reauth status storage (keyed by (daemon_id, request_id)) + pub daemon_reauth_status: DashMap<(Uuid, Uuid), DaemonReauthStatus>, } impl AppState { @@ -694,6 +718,7 @@ impl AppState { jwt_verifier, pending_worktree_info: DashMap::new(), tts_engine: OnceCell::new(), + daemon_reauth_status: DashMap::new(), } } @@ -1201,6 +1226,41 @@ impl AppState { } // ========================================================================= + // Daemon Reauth Status + // ========================================================================= + + /// Store a daemon reauth status update (from daemon's ReauthStatus message). + pub fn set_daemon_reauth_status( + &self, + daemon_id: Uuid, + request_id: Uuid, + status: String, + login_url: Option<String>, + error: Option<String>, + ) { + self.daemon_reauth_status.insert( + (daemon_id, request_id), + DaemonReauthStatus { + request_id, + status, + login_url, + error, + }, + ); + } + + /// Get a daemon reauth status (for frontend polling). + pub fn get_daemon_reauth_status( + &self, + daemon_id: Uuid, + request_id: Uuid, + ) -> Option<DaemonReauthStatus> { + self.daemon_reauth_status + .get(&(daemon_id, request_id)) + .map(|entry| entry.value().clone()) + } + + // ========================================================================= // Supervisor Notifications // ========================================================================= |
