From 347056180d22d1a62b69e6d8d019be04a8e57857 Mon Sep 17 00:00:00 2001 From: DIodide Date: Tue, 21 Apr 2026 17:42:38 -0400 Subject: [PATCH 1/2] fix: batch of 8 onboarding, MCP, harness, chat, and auth bug fixes - #6, #7: onboarding Save Draft validates name + model, status-branched navigation - #8: MCP tiger_junction servers show Needs Verification before netid link - #9: chat header MCP badges refresh after OAuth reconnect - #14: harness detail page exposes a draft/started/stopped status Select - #17: interrupted assistant messages persist with interruptionReason across all error paths - #22: first-response spinner uses RoseCurveSpinner with rotating flavor text - #23: new /sign-up route, landing CTAs target it, Clerk cross-links work - dev-only: CORS allowlist covers localhost and 127.0.0.1 ports 3000-3020 --- apps/web/src/components/mcp-server-status.tsx | 59 +++++- .../components/pending-response-indicator.tsx | 73 ++++++++ apps/web/src/routeTree.gen.ts | 21 +++ apps/web/src/routes/chat/index.tsx | 172 ++++++++++-------- apps/web/src/routes/harnesses/$harnessId.tsx | 55 ++++-- apps/web/src/routes/index.tsx | 8 +- apps/web/src/routes/onboarding.tsx | 30 ++- apps/web/src/routes/sign-in.tsx | 1 + apps/web/src/routes/sign-up.tsx | 95 ++++++++++ apps/web/src/routes/workspaces/index.tsx | 16 +- packages/convex-backend/convex/messages.ts | 6 + packages/convex-backend/convex/schema.ts | 1 + packages/fastapi/app/main.py | 13 +- packages/fastapi/app/routes/chat.py | 112 +++++++++++- packages/fastapi/app/routes/mcp_health.py | 12 +- packages/fastapi/app/services/convex.py | 6 + 16 files changed, 557 insertions(+), 123 deletions(-) create mode 100644 apps/web/src/components/pending-response-indicator.tsx create mode 100644 apps/web/src/routes/sign-up.tsx diff --git a/apps/web/src/components/mcp-server-status.tsx b/apps/web/src/components/mcp-server-status.tsx index d736739..8d149ae 100644 --- a/apps/web/src/components/mcp-server-status.tsx +++ b/apps/web/src/components/mcp-server-status.tsx @@ -2,7 +2,7 @@ import { useAuth } from "@clerk/tanstack-react-start"; import { convexQuery } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useQuery } from "@tanstack/react-query"; -import { AlertTriangle, Server, Shield } from "lucide-react"; +import { AlertTriangle, GraduationCap, Server, Shield } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; @@ -98,7 +98,12 @@ export type HealthStatus = | "unreachable" | "auth_required"; -type ServerStatus = "connected" | "expired" | "disconnected" | "checking"; +type ServerStatus = + | "connected" + | "expired" + | "disconnected" + | "checking" + | "needs_verification"; function getServerStatus( server: McpServer, @@ -126,7 +131,15 @@ function getServerStatus( return "connected"; } - // For non-OAuth servers: use health check result + // For Princeton servers: auth_required means no verified netid on the account + if (server.authType === "tiger_junction") { + if (healthStatus === "auth_required") return "needs_verification"; + if (healthStatus === "unreachable") return "disconnected"; + if (healthStatus === "reachable") return "connected"; + return "checking"; + } + + // For bearer / none servers: use health check result if (healthStatus === "unreachable") return "disconnected"; if (healthStatus === "auth_required") return "disconnected"; if (healthStatus === "reachable") return "connected"; @@ -139,6 +152,7 @@ const STATUS_DOT: Record = { expired: "bg-amber-400", disconnected: "bg-red-400", checking: "bg-muted-foreground/40", + needs_verification: "bg-amber-400", }; const STATUS_LABEL: Record = { @@ -146,6 +160,7 @@ const STATUS_LABEL: Record = { expired: "Token expired", disconnected: "Unreachable", checking: "Checking…", + needs_verification: "Verify Princeton account", }; export function McpServerStatus({ @@ -186,7 +201,10 @@ export function McpServerStatus({ const allConnected = statuses.every((s) => s.status === "connected"); const hasIssue = statuses.some( - (s) => s.status === "expired" || s.status === "disconnected", + (s) => + s.status === "expired" || + s.status === "disconnected" || + s.status === "needs_verification", ); const anyChecking = statuses.some((s) => s.status === "checking"); @@ -315,17 +333,44 @@ function McpServerRow({ Reconnect )} - {server.authType !== "oauth" && status === "connected" && ( + {status === "connected" && server.authType === "bearer" && ( - {server.authType === "bearer" ? "Key" : "Open"} + Key )} - {server.authType === "oauth" && status === "connected" && ( + {status === "connected" && server.authType === "none" && ( + + Public + + )} + {status === "connected" && server.authType === "tiger_junction" && ( + + + Princeton + + )} + {status === "connected" && server.authType === "oauth" && (
OAuth )} + {status === "needs_verification" && ( + + + + + Verify + + + + Open this harness's settings to verify your Princeton account. + + + )}
); } diff --git a/apps/web/src/components/pending-response-indicator.tsx b/apps/web/src/components/pending-response-indicator.tsx new file mode 100644 index 0000000..38332b2 --- /dev/null +++ b/apps/web/src/components/pending-response-indicator.tsx @@ -0,0 +1,73 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; +import { RoseCurveSpinner } from "./rose-curve-spinner"; + +const FLAVOR_MESSAGES = [ + "Thinking…", + "Warming up…", + "Consulting the tools…", + "Wiring up neurons…", + "Querying the oracle…", + "Reticulating splines…", + "Gathering thoughts…", + "Brewing a response…", + "Summoning tokens…", + "Sharpening the quill…", + "Reading the room…", + "Chasing down sources…", + "Following the thread…", + "Picking the right words…", + "Double-checking the math…", + "Aligning the stars…", +]; + +const ROTATE_MS = 2600; + +function pickInitial(): number { + return Math.floor(Math.random() * FLAVOR_MESSAGES.length); +} + +export function PendingResponseIndicator({ + className, +}: { + className?: string; +}) { + const [idx, setIdx] = useState(pickInitial); + + useEffect(() => { + const prefersReduced = + window?.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + if (prefersReduced) return; + const id = setInterval(() => { + setIdx((prev) => (prev + 1) % FLAVOR_MESSAGES.length); + }, ROTATE_MS); + return () => clearInterval(id); + }, []); + + const message = FLAVOR_MESSAGES[idx]; + + return ( +
+ + + + {message} + + +
+ ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index e3641d4..26d7076 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SignUpRouteImport } from './routes/sign-up' import { Route as SignInRouteImport } from './routes/sign-in' import { Route as OnboardingRouteImport } from './routes/onboarding' import { Route as AppRouteImport } from './routes/app' @@ -21,6 +22,11 @@ import { Route as SandboxesCreate_sandboxRouteImport } from './routes/sandboxes/ import { Route as SandboxesSandboxIdRouteImport } from './routes/sandboxes/$sandboxId' import { Route as HarnessesHarnessIdRouteImport } from './routes/harnesses/$harnessId' +const SignUpRoute = SignUpRouteImport.update({ + id: '/sign-up', + path: '/sign-up', + getParentRoute: () => rootRouteImport, +} as any) const SignInRoute = SignInRouteImport.update({ id: '/sign-in', path: '/sign-in', @@ -82,6 +88,7 @@ export interface FileRoutesByFullPath { '/app': typeof AppRoute '/onboarding': typeof OnboardingRoute '/sign-in': typeof SignInRoute + '/sign-up': typeof SignUpRoute '/harnesses/$harnessId': typeof HarnessesHarnessIdRoute '/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute '/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute @@ -95,6 +102,7 @@ export interface FileRoutesByTo { '/app': typeof AppRoute '/onboarding': typeof OnboardingRoute '/sign-in': typeof SignInRoute + '/sign-up': typeof SignUpRoute '/harnesses/$harnessId': typeof HarnessesHarnessIdRoute '/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute '/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute @@ -109,6 +117,7 @@ export interface FileRoutesById { '/app': typeof AppRoute '/onboarding': typeof OnboardingRoute '/sign-in': typeof SignInRoute + '/sign-up': typeof SignUpRoute '/harnesses/$harnessId': typeof HarnessesHarnessIdRoute '/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute '/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute @@ -124,6 +133,7 @@ export interface FileRouteTypes { | '/app' | '/onboarding' | '/sign-in' + | '/sign-up' | '/harnesses/$harnessId' | '/sandboxes/$sandboxId' | '/sandboxes/create_sandbox' @@ -137,6 +147,7 @@ export interface FileRouteTypes { | '/app' | '/onboarding' | '/sign-in' + | '/sign-up' | '/harnesses/$harnessId' | '/sandboxes/$sandboxId' | '/sandboxes/create_sandbox' @@ -150,6 +161,7 @@ export interface FileRouteTypes { | '/app' | '/onboarding' | '/sign-in' + | '/sign-up' | '/harnesses/$harnessId' | '/sandboxes/$sandboxId' | '/sandboxes/create_sandbox' @@ -164,6 +176,7 @@ export interface RootRouteChildren { AppRoute: typeof AppRoute OnboardingRoute: typeof OnboardingRoute SignInRoute: typeof SignInRoute + SignUpRoute: typeof SignUpRoute HarnessesHarnessIdRoute: typeof HarnessesHarnessIdRoute SandboxesSandboxIdRoute: typeof SandboxesSandboxIdRoute SandboxesCreate_sandboxRoute: typeof SandboxesCreate_sandboxRoute @@ -175,6 +188,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/sign-up': { + id: '/sign-up' + path: '/sign-up' + fullPath: '/sign-up' + preLoaderRoute: typeof SignUpRouteImport + parentRoute: typeof rootRouteImport + } '/sign-in': { id: '/sign-in' path: '/sign-in' @@ -260,6 +280,7 @@ const rootRouteChildren: RootRouteChildren = { AppRoute: AppRoute, OnboardingRoute: OnboardingRoute, SignInRoute: SignInRoute, + SignUpRoute: SignUpRoute, HarnessesHarnessIdRoute: HarnessesHarnessIdRoute, SandboxesSandboxIdRoute: SandboxesSandboxIdRoute, SandboxesCreate_sandboxRoute: SandboxesCreate_sandboxRoute, diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 8234082..0547f89 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -64,6 +64,7 @@ import { MessageActions, } from "../../components/message-actions"; import { MessageAttachments } from "../../components/message-attachments"; +import { PendingResponseIndicator } from "../../components/pending-response-indicator"; import { RoseCurveSpinner } from "../../components/rose-curve-spinner"; import { SandboxPanel } from "../../components/sandbox/sandbox-panel"; import { SandboxResult } from "../../components/sandbox-result"; @@ -708,83 +709,97 @@ function ChatPage() { ), ); - // Health-check MCP servers when harness changes - // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes - useEffect(() => { - if (!activeHarness || activeHarness.mcpServers.length === 0) { - setMcpHealthStatuses({}); - return; - } - - // Set all servers to "checking" - const checking: Record = {}; - for (const s of activeHarness.mcpServers) { - checking[s.url] = "checking"; - } - setMcpHealthStatuses(checking); - - let cancelled = false; - - const runCheck = async () => { - try { - const token = await getToken(); - const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - mcp_servers: activeHarness.mcpServers.map((s) => ({ - name: s.name, - url: s.url, - auth_type: s.authType, - ...(s.authToken ? { auth_token: s.authToken } : {}), - })), - force: true, - }), - }); + // Health-check MCP servers when harness changes, or on-demand via refreshHealth. + const healthCheckRunRef = useRef<{ cancel: () => void } | null>(null); + const runHealthCheck = useCallback( + ( + servers: Array<{ + name: string; + url: string; + authType: McpAuthType; + authToken?: string; + }>, + ) => { + healthCheckRunRef.current?.cancel(); - if (cancelled) return; + if (servers.length === 0) { + setMcpHealthStatuses({}); + return; + } - if (!res.ok) { - const fallback: Record = {}; - for (const s of activeHarness.mcpServers) { - fallback[s.url] = "unreachable"; + const checking: Record = {}; + for (const s of servers) checking[s.url] = "checking"; + setMcpHealthStatuses(checking); + + let cancelled = false; + const run = async () => { + try { + const token = await getToken(); + const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + mcp_servers: servers.map((s) => ({ + name: s.name, + url: s.url, + auth_type: s.authType, + ...(s.authToken ? { auth_token: s.authToken } : {}), + })), + force: true, + }), + }); + if (cancelled) return; + if (!res.ok) { + const fallback: Record = {}; + for (const s of servers) fallback[s.url] = "unreachable"; + setMcpHealthStatuses(fallback); + return; } + const data = await res.json(); + if (cancelled) return; + const statuses: Record = {}; + for (const server of data.servers) { + if (server.status === "ok") statuses[server.url] = "reachable"; + else if (server.status === "auth_required") statuses[server.url] = "auth_required"; + else statuses[server.url] = "unreachable"; + } + setMcpHealthStatuses(statuses); + } catch { + if (cancelled) return; + const fallback: Record = {}; + for (const s of servers) fallback[s.url] = "unreachable"; setMcpHealthStatuses(fallback); - return; } + }; - const data = await res.json(); - if (cancelled) return; + run(); + healthCheckRunRef.current = { + cancel: () => { + cancelled = true; + }, + }; + }, + [getToken], + ); - const statuses: Record = {}; - for (const server of data.servers) { - if (server.status === "ok") { - statuses[server.url] = "reachable"; - } else if (server.status === "auth_required") { - statuses[server.url] = "auth_required"; - } else { - statuses[server.url] = "unreachable"; - } - } - setMcpHealthStatuses(statuses); - } catch { - if (cancelled) return; - const fallback: Record = {}; - for (const s of activeHarness.mcpServers) { - fallback[s.url] = "unreachable"; - } - setMcpHealthStatuses(fallback); - } - }; + const refreshHealth = useCallback(() => { + if (activeHarness) runHealthCheck(activeHarness.mcpServers); + }, [activeHarness, runHealthCheck]); - runCheck(); + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes + useEffect(() => { + if (!activeHarness) { + setMcpHealthStatuses({}); + return; + } + runHealthCheck(activeHarness.mcpServers); return () => { - cancelled = true; + healthCheckRunRef.current?.cancel(); }; - }, [activeHarness?._id, getToken]); + }, [activeHarness?._id]); // Sync slash commands: fetch from MCP servers, upsert into commands table, // and store the resulting IDs on the harness's mcpServers. @@ -1143,6 +1158,7 @@ function ChatPage() { isStreaming={isActiveConvoStreaming} mcpHealthStatuses={mcpHealthStatuses} onRefreshCommands={refreshCommands} + onRefreshHealth={refreshHealth} onAddSkill={handleAddSkill} onRemoveSkill={handleRemoveSkill} /> @@ -2018,6 +2034,7 @@ function ChatHeader({ isStreaming, mcpHealthStatuses, onRefreshCommands, + onRefreshHealth, onAddSkill, onRemoveSkill, }: { @@ -2059,6 +2076,7 @@ function ChatHeader({ isStreaming: boolean; mcpHealthStatuses?: Record; onRefreshCommands: () => void; + onRefreshHealth: () => void; onAddSkill: (skill: SkillEntry) => void; onRemoveSkill: (skill: SkillEntry) => void; }) { @@ -2120,7 +2138,10 @@ function ChatHeader({ { + onRefreshHealth(); + onRefreshCommands(); + }} /> )} @@ -2529,9 +2550,13 @@ function ChatMessages({ lastMsg.content === pendingDoneContent; const isActivelyStreaming = streamingContent !== null || streamingReasoning !== null; - // Show the streaming bubble when we have content, reasoning, or tool calls, but Convex hasn't synced yet + // Show the streaming bubble while we're waiting for or receiving a response + // (content, reasoning, tool calls) but Convex hasn't synced yet. Include + // `isStreaming` so the bubble appears immediately with a pending spinner + // before the first chunk arrives. const showStreamingBubble = - (streamingContent !== null || + (isStreaming || + streamingContent !== null || streamingReasoning !== null || activeToolCalls.length > 0) && !convexHasMessage; @@ -2996,12 +3021,7 @@ function ChatMessages({ }) : !streamingReasoning && activeToolCalls.length === 0 && - !streamingContent && ( - - )} + !streamingContent && } {displayMode === "developer" && streamUsage && (
diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx index 1a2978b..7d659e9 100644 --- a/apps/web/src/routes/harnesses/$harnessId.tsx +++ b/apps/web/src/routes/harnesses/$harnessId.tsx @@ -89,6 +89,8 @@ type SandboxConfig = { type Sandbox = Doc<"sandboxes">; +type HarnessStatus = "draft" | "started" | "stopped"; + function getResourceTierFromSandbox( sandbox: Sandbox, ): SandboxConfig["resourceTier"] { @@ -154,6 +156,7 @@ function HarnessEditPage() { setName(null); setModel(null); + setStatus(null); setMcpServers(null); setSkills(null); setSystemPrompt(null); @@ -247,6 +250,7 @@ function HarnessEditPage() { const [name, setName] = useState(null); const [model, setModel] = useState(null); + const [status, setStatus] = useState(null); const [mcpServers, setMcpServers] = useState(null); const [skills, setSkills] = useState(null); const [skillsBrowserOpen, setSkillsBrowserOpen] = useState(false); @@ -259,6 +263,8 @@ function HarnessEditPage() { // Use local state if edited, otherwise fall back to server data const currentName = name ?? harness?.name ?? ""; const currentModel = model ?? harness?.model ?? ""; + const currentStatus: HarnessStatus = + status ?? (harness?.status as HarnessStatus | undefined) ?? "draft"; const currentMcpServers = mcpServers ?? harness?.mcpServers ?? []; const currentSkills: SkillEntry[] = skills ?? harness?.skills ?? []; @@ -295,6 +301,7 @@ function HarnessEditPage() { const hasChanges = name !== null || model !== null || + status !== null || mcpServers !== null || skills !== null || systemPrompt !== null || @@ -360,6 +367,7 @@ function HarnessEditPage() { } if (name !== null) updates.name = name; if (model !== null) updates.model = model; + if (status !== null) updates.status = status; if (mcpServers !== null) updates.mcpServers = mcpServers; if (skills !== null) updates.skills = skills; if (systemPrompt !== null) updates.systemPrompt = systemPrompt.trim(); @@ -814,20 +822,39 @@ function HarnessEditPage() {

Status

-
-
- - {harness.status} - -
+ + {harness.status === "draft" && currentStatus !== "draft" && ( +

+ Saving will promote this harness out of draft. +

+ )}
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 7af172c..6cf8826 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -535,7 +535,7 @@ function LandingNav() { Log in diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx index 0fb0ae2..2f6455a 100644 --- a/apps/web/src/routes/sign-in.tsx +++ b/apps/web/src/routes/sign-in.tsx @@ -65,6 +65,7 @@ function SignInPage() { +
+ +
+ + + Harness + +
+
+ + +

+ Equip your AI agents for anything. +

+

+ Create, manage, and deploy custom tool configurations for AI agents + — switching contexts in seconds, not hours. +

+
+ + + © 2026 Harness. All rights reserved. + +
+ +
+ +
+ + + Harness + +
+ +
+
+ + ); +} diff --git a/apps/web/src/routes/workspaces/index.tsx b/apps/web/src/routes/workspaces/index.tsx index b3dcef2..0345c28 100644 --- a/apps/web/src/routes/workspaces/index.tsx +++ b/apps/web/src/routes/workspaces/index.tsx @@ -67,6 +67,7 @@ import { MessageActions, } from "../../components/message-actions"; import { MessageAttachments } from "../../components/message-attachments"; +import { PendingResponseIndicator } from "../../components/pending-response-indicator"; import { RoseCurveSpinner } from "../../components/rose-curve-spinner"; import { SandboxPanel } from "../../components/sandbox/sandbox-panel"; import { SandboxResult } from "../../components/sandbox-result"; @@ -2963,9 +2964,13 @@ function ChatMessages({ lastMsg.content === pendingDoneContent; const isActivelyStreaming = streamingContent !== null || streamingReasoning !== null; - // Show the streaming bubble when we have content, reasoning, or tool calls, but Convex hasn't synced yet + // Show the streaming bubble while we're waiting for or receiving a response + // (content, reasoning, tool calls) but Convex hasn't synced yet. Include + // `isStreaming` so the bubble appears immediately with a pending spinner + // before the first chunk arrives. const showStreamingBubble = - (streamingContent !== null || + (isStreaming || + streamingContent !== null || streamingReasoning !== null || activeToolCalls.length > 0) && !convexHasMessage; @@ -3430,12 +3435,7 @@ function ChatMessages({ }) : !streamingReasoning && activeToolCalls.length === 0 && - !streamingContent && ( - - )} + !streamingContent && } {displayMode === "developer" && streamUsage && (
diff --git a/packages/convex-backend/convex/messages.ts b/packages/convex-backend/convex/messages.ts index f9a5ee2..c05367d 100644 --- a/packages/convex-backend/convex/messages.ts +++ b/packages/convex-backend/convex/messages.ts @@ -201,6 +201,8 @@ export const saveAssistantMessage = internalMutation({ }), ), model: v.optional(v.string()), + interrupted: v.optional(v.boolean()), + interruptionReason: v.optional(v.string()), }, handler: async (ctx, args) => { const convo = await ctx.db.get(args.conversationId); @@ -221,6 +223,10 @@ export const saveAssistantMessage = internalMutation({ : {}), ...(args.usage ? { usage: args.usage } : {}), ...(args.model ? { model: args.model } : {}), + ...(args.interrupted ? { interrupted: true } : {}), + ...(args.interruptionReason + ? { interruptionReason: args.interruptionReason } + : {}), }); await ctx.db.patch(args.conversationId, { diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts index 19974c7..953765b 100644 --- a/packages/convex-backend/convex/schema.ts +++ b/packages/convex-backend/convex/schema.ts @@ -159,6 +159,7 @@ export default defineSchema({ ), model: v.optional(v.string()), interrupted: v.optional(v.boolean()), + interruptionReason: v.optional(v.string()), attachments: v.optional( v.array( v.object({ diff --git a/packages/fastapi/app/main.py b/packages/fastapi/app/main.py index 655b9f8..0d70eb5 100644 --- a/packages/fastapi/app/main.py +++ b/packages/fastapi/app/main.py @@ -42,16 +42,17 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +_local_dev_origins = [ + f"http://{host}:{port}" + for host in ("localhost", "127.0.0.1") + for port in range(3000, 3021) +] + app.add_middleware( CORSMiddleware, allow_origins=[ settings.frontend_url, - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:3003", - "http://127.0.0.1:3000", - "http://127.0.0.1:3001", - "http://127.0.0.1:3003", + *_local_dev_origins, "http://127.0.0.1:57177", "https://harness.nz", "https://staging.harness.nz", diff --git a/packages/fastapi/app/routes/chat.py b/packages/fastapi/app/routes/chat.py index bcb396b..3a8e551 100644 --- a/packages/fastapi/app/routes/chat.py +++ b/packages/fastapi/app/routes/chat.py @@ -645,6 +645,18 @@ async def event_generator(): "OpenRouter HTTP error: %s", e.response.status_code, ) + await _save_interrupted( + http_client, + body, + user_id, + collected_content, + all_reasoning, + all_tool_calls_history, + all_parts, + collected_usage, + collected_model, + f"Upstream service error ({e.response.status_code})", + ) yield { "event": "error", "data": json.dumps( @@ -656,6 +668,18 @@ async def event_generator(): return except httpx.HTTPError as e: logger.error("HTTP error during chat stream: %s", e) + await _save_interrupted( + http_client, + body, + user_id, + collected_content, + all_reasoning, + all_tool_calls_history, + all_parts, + collected_usage, + collected_model, + "Service unavailable", + ) yield { "event": "error", "data": json.dumps({"message": "Service unavailable"}), @@ -666,6 +690,18 @@ async def event_generator(): "Unexpected error in chat stream for conversation '%s'", body.conversation_id, ) + await _save_interrupted( + http_client, + body, + user_id, + collected_content, + all_reasoning, + all_tool_calls_history, + all_parts, + collected_usage, + collected_model, + "Internal server error", + ) yield { "event": "error", "data": json.dumps({"message": "Internal server error"}), @@ -904,15 +940,89 @@ async def _execute_tool(tool_info: dict) -> tuple[dict, str]: body.conversation_id, ) - # Max iterations reached + # Max iterations reached — persist everything we have so the user can + # see the partial trace instead of it silently vanishing. logger.warning( "Max tool iterations (%d) reached for conversation '%s'", MAX_TOOL_ITERATIONS, body.conversation_id, ) + await _save_interrupted( + http_client, + body, + user_id, + "", + all_reasoning, + all_tool_calls_history, + all_parts, + collected_usage, + collected_model, + f"Max tool call iterations ({MAX_TOOL_ITERATIONS}) reached", + ) yield { "event": "error", "data": json.dumps({"message": "Max tool call iterations reached"}), } return EventSourceResponse(event_generator()) + + +async def _save_interrupted( + http_client: httpx.AsyncClient, + body: "ChatRequest", + user_id: str, + content: str, + reasoning: str, + tool_calls_history: list[dict], + parts: list[dict], + collected_usage: dict | None, + collected_model: str | None, + reason: str, +) -> None: + """Persist partial assistant state when the stream ends before a normal finish.""" + usage_for_convex: dict | None = None + if collected_usage: + usage_for_convex = { + "promptTokens": collected_usage.get("prompt_tokens", 0), + "completionTokens": collected_usage.get("completion_tokens", 0), + "totalTokens": collected_usage.get("total_tokens", 0), + } + cost = collected_usage.get("cost") + if cost is not None: + usage_for_convex["cost"] = cost + + try: + await save_assistant_message( + http_client, + body.conversation_id, + content, + reasoning=reasoning or None, + tool_calls=tool_calls_history or None, + parts=parts or None, + usage=usage_for_convex, + model=collected_model, + interrupted=True, + interruption_reason=reason, + ) + except Exception: + logger.exception( + "Failed to persist interrupted message for conversation '%s'", + body.conversation_id, + ) + + if collected_usage: + try: + await record_usage( + http_client, + user_id=user_id, + conversation_id=body.conversation_id, + harness_id=body.harness.harness_id, + harness_name=body.harness.name, + model=collected_model or body.harness.model, + usage_data=collected_usage, + ) + except Exception: + logger.exception( + "Failed to record usage for interrupted conversation '%s'", + body.conversation_id, + ) diff --git a/packages/fastapi/app/routes/mcp_health.py b/packages/fastapi/app/routes/mcp_health.py index 43a70e0..4c54b57 100644 --- a/packages/fastapi/app/routes/mcp_health.py +++ b/packages/fastapi/app/routes/mcp_health.py @@ -15,6 +15,7 @@ check_server_health, evict_session_cache, list_tools, + resolve_princeton_netid, ) from app.services.openrouter import complete_chat @@ -44,6 +45,11 @@ async def _check_one( user_ctx: UserContext | None, force: bool, ) -> ServerHealth: + # Princeton MCPs need a verified netid — reachable without one is misleading, + # since actual tool calls fail server-side. Surface it as auth_required. + if server.auth_type == "tiger_junction" and (user_ctx is None or not user_ctx.princeton_netid): + return ServerHealth(name=server.name, url=server.url, reachable=False, status="auth_required") + if force: evict_session_cache(server.url) try: @@ -64,7 +70,8 @@ async def check_health( http_client: httpx.AsyncClient = Depends(get_http_client), user: dict = Depends(get_current_user), ): - user_ctx = UserContext(user_id=user.get("sub")) + netid = await resolve_princeton_netid(http_client, user) + user_ctx = UserContext(user_id=user.get("sub"), princeton_netid=netid) results = await asyncio.gather( *[_check_one(http_client, s, user_ctx, body.force) for s in body.mcp_servers], @@ -96,7 +103,8 @@ async def generate_prompts( http_client: httpx.AsyncClient = Depends(get_http_client), user: dict = Depends(get_current_user), ): - user_ctx = UserContext(user_id=user.get("sub")) + netid = await resolve_princeton_netid(http_client, user) + user_ctx = UserContext(user_id=user.get("sub"), princeton_netid=netid) if not body.mcp_servers: return GeneratePromptsResponse(prompts=[]) diff --git a/packages/fastapi/app/services/convex.py b/packages/fastapi/app/services/convex.py index 0847132..40c311b 100644 --- a/packages/fastapi/app/services/convex.py +++ b/packages/fastapi/app/services/convex.py @@ -47,6 +47,8 @@ async def save_assistant_message( parts: list[dict] | None = None, usage: dict | None = None, model: str | None = None, + interrupted: bool = False, + interruption_reason: str | None = None, ) -> None: """Save an assistant message to Convex via the HTTP API. @@ -72,6 +74,10 @@ async def save_assistant_message( args["usage"] = usage if model: args["model"] = model + if interrupted: + args["interrupted"] = True + if interruption_reason: + args["interruptionReason"] = interruption_reason try: resp = await http_client.post( From 455d7dc895cf9e2d1330d7bd2583aeba2e8394bc Mon Sep 17 00:00:00 2001 From: DIodide Date: Tue, 21 Apr 2026 18:16:26 -0400 Subject: [PATCH 2/2] fix(workspaces): wire refreshHealth through MCP reconnect surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the refreshHealth callback plumbing from chat/index.tsx so the McpServerStatus OAuth reconnect button re-runs the health check after a reconnect — previously the status would stay stale until a harness swap. Also adds pytest coverage for the interrupted / interruptionReason branches in save_assistant_message that were added in this PR but previously untested. --- apps/web/src/routes/workspaces/index.tsx | 154 ++++++++++-------- packages/fastapi/tests/test_convex_service.py | 26 +++ 2 files changed, 112 insertions(+), 68 deletions(-) diff --git a/apps/web/src/routes/workspaces/index.tsx b/apps/web/src/routes/workspaces/index.tsx index 0345c28..e43dc62 100644 --- a/apps/web/src/routes/workspaces/index.tsx +++ b/apps/web/src/routes/workspaces/index.tsx @@ -748,83 +748,97 @@ function ChatPage() { sessionModel, ]); - // Health-check MCP servers when harness changes - // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes - useEffect(() => { - if (!activeHarness || activeHarness.mcpServers.length === 0) { - setMcpHealthStatuses({}); - return; - } - - // Set all servers to "checking" - const checking: Record = {}; - for (const s of activeHarness.mcpServers) { - checking[s.url] = "checking"; - } - setMcpHealthStatuses(checking); - - let cancelled = false; - - const runCheck = async () => { - try { - const token = await getToken(); - const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - mcp_servers: activeHarness.mcpServers.map((s) => ({ - name: s.name, - url: s.url, - auth_type: s.authType, - ...(s.authToken ? { auth_token: s.authToken } : {}), - })), - force: true, - }), - }); + // Health-check MCP servers when harness changes, or on-demand via refreshHealth. + const healthCheckRunRef = useRef<{ cancel: () => void } | null>(null); + const runHealthCheck = useCallback( + ( + servers: Array<{ + name: string; + url: string; + authType: McpAuthType; + authToken?: string; + }>, + ) => { + healthCheckRunRef.current?.cancel(); - if (cancelled) return; + if (servers.length === 0) { + setMcpHealthStatuses({}); + return; + } - if (!res.ok) { - const fallback: Record = {}; - for (const s of activeHarness.mcpServers) { - fallback[s.url] = "unreachable"; + const checking: Record = {}; + for (const s of servers) checking[s.url] = "checking"; + setMcpHealthStatuses(checking); + + let cancelled = false; + const run = async () => { + try { + const token = await getToken(); + const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + mcp_servers: servers.map((s) => ({ + name: s.name, + url: s.url, + auth_type: s.authType, + ...(s.authToken ? { auth_token: s.authToken } : {}), + })), + force: true, + }), + }); + if (cancelled) return; + if (!res.ok) { + const fallback: Record = {}; + for (const s of servers) fallback[s.url] = "unreachable"; + setMcpHealthStatuses(fallback); + return; + } + const data = await res.json(); + if (cancelled) return; + const statuses: Record = {}; + for (const server of data.servers) { + if (server.status === "ok") statuses[server.url] = "reachable"; + else if (server.status === "auth_required") statuses[server.url] = "auth_required"; + else statuses[server.url] = "unreachable"; } + setMcpHealthStatuses(statuses); + } catch { + if (cancelled) return; + const fallback: Record = {}; + for (const s of servers) fallback[s.url] = "unreachable"; setMcpHealthStatuses(fallback); - return; } + }; - const data = await res.json(); - if (cancelled) return; - - const statuses: Record = {}; - for (const server of data.servers) { - if (server.status === "ok") { - statuses[server.url] = "reachable"; - } else if (server.status === "auth_required") { - statuses[server.url] = "auth_required"; - } else { - statuses[server.url] = "unreachable"; - } - } - setMcpHealthStatuses(statuses); - } catch { - if (cancelled) return; - const fallback: Record = {}; - for (const s of activeHarness.mcpServers) { - fallback[s.url] = "unreachable"; - } - setMcpHealthStatuses(fallback); - } - }; + run(); + healthCheckRunRef.current = { + cancel: () => { + cancelled = true; + }, + }; + }, + [getToken], + ); - runCheck(); + const refreshHealth = useCallback(() => { + if (activeHarness) runHealthCheck(activeHarness.mcpServers); + }, [activeHarness, runHealthCheck]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes + useEffect(() => { + if (!activeHarness) { + setMcpHealthStatuses({}); + return; + } + runHealthCheck(activeHarness.mcpServers); return () => { - cancelled = true; + healthCheckRunRef.current?.cancel(); }; - }, [activeHarness?._id, getToken]); + }, [activeHarness?._id]); const handleInterrupt = useCallback( (convoId: string) => { @@ -1123,6 +1137,7 @@ function ChatPage() { sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} mcpHealthStatuses={mcpHealthStatuses} + onRefreshHealth={refreshHealth} onSwapSandbox={handleSwapSandbox} onAddSkill={handleAddSkill} onRemoveSkill={handleRemoveSkill} @@ -2503,6 +2518,7 @@ function ChatHeader({ sidebarOpen, onToggleSidebar, mcpHealthStatuses, + onRefreshHealth, onSwapSandbox, onAddSkill, onRemoveSkill, @@ -2539,6 +2555,7 @@ function ChatHeader({ sidebarOpen: boolean; onToggleSidebar: () => void; mcpHealthStatuses?: Record; + onRefreshHealth: () => void; onSwapSandbox: (sandboxId: Id<"sandboxes">) => void; onAddSkill: (skill: SkillEntry) => void; onRemoveSkill: (skill: SkillEntry) => void; @@ -2607,6 +2624,7 @@ function ChatHeader({ )} diff --git a/packages/fastapi/tests/test_convex_service.py b/packages/fastapi/tests/test_convex_service.py index 742f693..1eb75b0 100644 --- a/packages/fastapi/tests/test_convex_service.py +++ b/packages/fastapi/tests/test_convex_service.py @@ -94,6 +94,32 @@ async def test_includes_optional_fields_when_provided(self, convex_settings): assert body["args"]["toolCalls"] == [{"name": "t"}] assert body["args"]["model"] == "gpt-4o" + @respx.mock + async def test_includes_interrupted_fields_when_provided(self, convex_settings): + route = respx.post(f"{CONVEX_URL}/api/mutation").mock( + return_value=httpx.Response(200, json={"value": None}) + ) + async with httpx.AsyncClient() as client: + await save_assistant_message( + client, "c1", "partial", + interrupted=True, + interruption_reason="Service unavailable", + ) + body = json.loads(route.calls.last.request.content) + assert body["args"]["interrupted"] is True + assert body["args"]["interruptionReason"] == "Service unavailable" + + @respx.mock + async def test_omits_interrupted_fields_by_default(self, convex_settings): + route = respx.post(f"{CONVEX_URL}/api/mutation").mock( + return_value=httpx.Response(200, json={"value": None}) + ) + async with httpx.AsyncClient() as client: + await save_assistant_message(client, "c1", "ok") + body = json.loads(route.calls.last.request.content) + assert "interrupted" not in body["args"] + assert "interruptionReason" not in body["args"] + @respx.mock async def test_swallows_http_status_errors(self, convex_settings): respx.post(f"{CONVEX_URL}/api/mutation").mock(