From e3bc2ec94891dfe72a93e9a00849e9064b87d6f5 Mon Sep 17 00:00:00 2001 From: AI Supervisor Date: Mon, 27 Jul 2026 02:45:19 -0700 Subject: [PATCH 1/9] feat: add Local Studio Harness operator surface --- .../src/app/api/harness/[...path]/route.ts | 26 ++ .../src/app/api/harness/proxy-to-harness.ts | 59 ++++ frontend/src/app/harness/page.tsx | 5 + .../src/features/harness/harness-page.tsx | 306 ++++++++++++++++++ .../src/features/shell/left-sidebar-nav.tsx | 3 +- 5 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/api/harness/[...path]/route.ts create mode 100644 frontend/src/app/api/harness/proxy-to-harness.ts create mode 100644 frontend/src/app/harness/page.tsx create mode 100644 frontend/src/features/harness/harness-page.tsx diff --git a/frontend/src/app/api/harness/[...path]/route.ts b/frontend/src/app/api/harness/[...path]/route.ts new file mode 100644 index 000000000..71bc16df8 --- /dev/null +++ b/frontend/src/app/api/harness/[...path]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyToHarness } from "@/app/api/harness/proxy-to-harness"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const denied = requireApiAccess(request); + if (denied) return denied; + const { path } = await params; + return proxyToHarness(request, path); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const denied = requireApiAccess(request); + if (denied) return denied; + const { path } = await params; + return proxyToHarness(request, path); +} diff --git a/frontend/src/app/api/harness/proxy-to-harness.ts b/frontend/src/app/api/harness/proxy-to-harness.ts new file mode 100644 index 000000000..d380a0773 --- /dev/null +++ b/frontend/src/app/api/harness/proxy-to-harness.ts @@ -0,0 +1,59 @@ +import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body"; + +const HOP_BY_HOP_REQUEST_HEADERS = ["host", "connection", "content-length", "accept-encoding"]; +const DEFAULT_HARNESS_URL = "http://127.0.0.1:8771"; + +export function harnessBaseUrl(): string { + const raw = process.env.LOCAL_STUDIO_HARNESS_URL?.trim(); + return (raw || DEFAULT_HARNESS_URL).replace(/\/+$/, ""); +} + +export async function proxyToHarness( + request: Request, + path: string[], + bodyLimitBytes = 256 * 1024, +): Promise { + const targetPath = path.map((part) => encodeURIComponent(part)).join("/"); + const sourceUrl = new URL(request.url); + const target = `${harnessBaseUrl()}/v1/${targetPath}${sourceUrl.search}`; + const headers = new Headers(request.headers); + for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name); + + let body: ArrayBuffer | undefined; + if (request.method !== "GET" && request.method !== "HEAD") { + const bounded = await readRequestBytesWithinLimit(request, bodyLimitBytes); + if (!bounded.ok) return Response.json({ error: bounded.error }, { status: bounded.status }); + body = new ArrayBuffer(bounded.value.byteLength); + new Uint8Array(body).set(bounded.value); + } + + let upstream: Response; + try { + upstream = await fetch(target, { + method: request.method, + headers, + body, + signal: request.signal, + cache: "no-store", + }); + } catch (error) { + if (request.signal.aborted) throw error; + return Response.json( + { + error: `agentic harness unreachable at ${harnessBaseUrl()}: ${ + error instanceof Error ? error.message : "fetch failed" + }`, + }, + { status: 502 }, + ); + } + + const responseHeaders = new Headers(upstream.headers); + responseHeaders.delete("content-length"); + responseHeaders.delete("content-encoding"); + responseHeaders.delete("transfer-encoding"); + return new Response(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); +} diff --git a/frontend/src/app/harness/page.tsx b/frontend/src/app/harness/page.tsx new file mode 100644 index 000000000..23dffc082 --- /dev/null +++ b/frontend/src/app/harness/page.tsx @@ -0,0 +1,5 @@ +import HarnessPage from "@/features/harness/harness-page"; + +export default function Page() { + return ; +} diff --git a/frontend/src/features/harness/harness-page.tsx b/frontend/src/features/harness/harness-page.tsx new file mode 100644 index 000000000..8e80c266a --- /dev/null +++ b/frontend/src/features/harness/harness-page.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useCallback, useState, type ReactNode } from "react"; +import { AppPage, Button, Card, ErrorBox, PageContainer, PageHeader, StatusPill } from "@/ui"; +import { useMountSubscription } from "@/hooks/use-mount-subscription"; +import { GitBranch, Play, RefreshCw, ShieldCheck } from "@/ui/icon-registry"; + +type HarnessRoute = { + id: string; + model_id: string; + node: string; + runtime: string; + role: string; + status: string; + status_reason?: string; + capabilities: string[]; + max_context_tokens: number; + eligible_for: string[]; +}; + +type HarnessTask = { + id?: string; + status?: string; + status_label?: string; + summary?: string; + human_title?: string; + artifacts?: Array<{ name?: string; path?: string }>; + events?: Array<{ seq?: number; summary?: string; checkpoint?: string }>; + metadata?: { demo?: { enabled?: boolean; model_used?: boolean; workspace?: string } }; +}; + +type RoutesResponse = { routes?: HarnessRoute[]; selection?: { policy?: string } }; +type TaskResponse = { task?: HarnessTask; current?: HarnessTask; events?: HarnessTask["events"] }; + +async function readJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { ...init, cache: "no-store" }); + const payload = (await response.json().catch(() => ({}))) as { error?: string } & T; + if (!response.ok) throw new Error(payload.error || `Harness request failed (${response.status})`); + return payload; +} + +function toneForStatus(status: string): "default" | "good" | "warning" | "danger" { + if (status === "ready" || status === "done" || status === "complete") return "good"; + if (status === "unavailable" || status === "blocked") return "danger"; + if (status === "degraded" || status === "needs_review") return "warning"; + return "default"; +} + +function formatContext(tokens: number): string { + if (tokens >= 1000) return `${Math.round(tokens / 1000)}k context`; + return `${tokens} context`; +} + +export default function HarnessPage() { + const [routes, setRoutes] = useState([]); + const [selectionPolicy, setSelectionPolicy] = useState("harness_decides"); + const [task, setTask] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + + const loadRoutes = useCallback(async () => { + const payload = await readJson("/api/harness/routes"); + setRoutes(payload.routes ?? []); + setSelectionPolicy(payload.selection?.policy ?? "harness_decides"); + }, []); + + const loadTask = useCallback(async (taskId?: string) => { + const target = taskId + ? `/api/harness/tasks/${encodeURIComponent(taskId)}` + : "/api/harness/tasks/current"; + const payload = await readJson(target); + const nextTask = payload.task ?? payload.current ?? null; + setTask(nextTask); + if (nextTask?.id) { + const eventPayload = await readJson( + `/api/harness/tasks/${encodeURIComponent(nextTask.id)}/events?after=0`, + ); + setEvents(eventPayload.events ?? nextTask.events ?? []); + } else { + setEvents([]); + } + }, []); + + const refresh = useCallback(async () => { + setError(""); + try { + await Promise.all([loadRoutes(), loadTask()]); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Harness is unavailable"); + } finally { + setLoading(false); + } + }, [loadRoutes, loadTask]); + + useMountSubscription(() => { + void refresh(); + }, [refresh]); + + useMountSubscription(() => { + if (!task?.id || ["done", "stopped", "blocked"].includes(task.status ?? "")) return; + const timer = window.setTimeout(() => { + void loadTask(task.id).catch((nextError) => + setError(nextError instanceof Error ? nextError.message : "Task refresh failed"), + ); + }, 3000); + return () => window.clearTimeout(timer); + }, [loadTask, task?.id, task?.status]); + + const runCanary = async () => { + setRunning(true); + setError(""); + try { + const payload = await readJson("/api/harness/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "read_only_canary", + objective: "Verify the Local Studio to Agentic Harness task boundary", + }), + }); + const nextTask = payload.task ?? null; + setTask(nextTask); + setEvents(nextTask?.events ?? []); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Canary could not start"); + } finally { + setRunning(false); + } + }; + + return ( + + + + + + + } + /> + + {error ? {error} : null} + +
+ + {loading && routes.length === 0 ? ( +

+ Checking Node1 and Node2... +

+ ) : routes.length === 0 ? ( +

No routes reported.

+ ) : ( +
+ {routes.map((route) => ( +
+
+
+ + + {route.model_id} + + + {route.status} + +
+
+ {route.node} + {route.runtime} + {route.role} + {formatContext(route.max_context_tokens)} +
+
+
+ {route.capabilities.map((capability) => ( + + {capability} + + ))} +
+
+ ))} +
+ )} +
+ + +
+ } + label="Harness" + value="Executor, router, durable state, events, artifacts" + /> + } + label="Local Studio" + value="Operator UI and model/chat surface" + /> +
+ The canary is intentionally credential-free and isolated. It proves the client path + without sending work to vLLM or changing your connected project. +
+
+
+
+ + + {!task?.id ? ( +

No task loaded.

+ ) : ( +
+
+
+ + {task.status_label ?? task.status ?? "unknown"} + + {task.id} +
+

+ {task.summary ?? task.human_title} +

+
+ {(events ?? []).slice(-6).map((event, index) => ( +
+ {event.seq ?? "·"} + {event.summary ?? event.checkpoint ?? "Harness event"} +
+ ))} +
+
+
+
+ Evidence +
+
+ {task.artifacts?.length ?? 0} artifact{task.artifacts?.length === 1 ? "" : "s"} +
+
+ {(task.artifacts ?? []).map((artifact) => ( +
+ {artifact.name ?? artifact.path} +
+ ))} +
+
+
+ )} +
+
+
+ ); +} + +function BoundaryRow({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { + return ( +
+ {icon} +
+
{label}
+
+ {value} +
+
+
+ ); +} diff --git a/frontend/src/features/shell/left-sidebar-nav.tsx b/frontend/src/features/shell/left-sidebar-nav.tsx index 63f599a43..f2b5405a7 100644 --- a/frontend/src/features/shell/left-sidebar-nav.tsx +++ b/frontend/src/features/shell/left-sidebar-nav.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { type ComponentType } from "react"; -import { Activity, Clock, ServerCog, TrendingUp } from "@/ui/icon-registry"; +import { Activity, Clock, GitFork, ServerCog, TrendingUp } from "@/ui/icon-registry"; export type IconComponent = ComponentType<{ className?: string; strokeWidth?: number }>; @@ -10,6 +10,7 @@ export type IconComponent = ComponentType<{ className?: string; strokeWidth?: nu export const tabs = [ { href: "/", label: "Status", icon: Activity }, { href: "/agent/automations", label: "Automations", icon: Clock }, + { href: "/harness", label: "Harness", icon: GitFork }, { href: "/configure", label: "Configure", icon: ServerCog }, { href: "/usage", label: "Usage", icon: TrendingUp }, ]; From 4c6b3b6a28ace45e0aec3a973cfc6a373f63fca8 Mon Sep 17 00:00:00 2001 From: AI Supervisor Date: Mon, 27 Jul 2026 08:55:16 -0700 Subject: [PATCH 2/9] feat: run vllm analysis from harness surface --- .../src/features/harness/harness-page.tsx | 91 ++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/harness/harness-page.tsx b/frontend/src/features/harness/harness-page.tsx index 8e80c266a..6f5e75181 100644 --- a/frontend/src/features/harness/harness-page.tsx +++ b/frontend/src/features/harness/harness-page.tsx @@ -26,7 +26,18 @@ type HarnessTask = { human_title?: string; artifacts?: Array<{ name?: string; path?: string }>; events?: Array<{ seq?: number; summary?: string; checkpoint?: string }>; - metadata?: { demo?: { enabled?: boolean; model_used?: boolean; workspace?: string } }; + metadata?: { + demo?: { enabled?: boolean; model_used?: boolean; workspace?: string }; + integration?: { + kind?: string; + route_id?: string; + model_id?: string; + node?: string; + runtime?: string; + model_used?: boolean; + connected_workspace_mutated?: boolean; + }; + }; }; type RoutesResponse = { routes?: HarnessRoute[]; selection?: { policy?: string } }; @@ -51,9 +62,12 @@ function formatContext(tokens: number): string { return `${tokens} context`; } +// The page coordinates route, task, event, and evidence state in one operator surface. +// eslint-disable-next-line complexity export default function HarnessPage() { const [routes, setRoutes] = useState([]); const [selectionPolicy, setSelectionPolicy] = useState("harness_decides"); + const [selectedRoute, setSelectedRoute] = useState("auto"); const [task, setTask] = useState(null); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -130,6 +144,31 @@ export default function HarnessPage() { } }; + const runAnalysis = async () => { + setRunning(true); + setError(""); + try { + const payload = await readJson("/api/harness/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "read_only_analysis", + ...(selectedRoute === "auto" ? {} : { route_id: selectedRoute }), + objective: + "Use the selected cluster vLLM route to confirm the Harness execution boundary. " + + "Report the selected model and explain that this analysis is read-only; do not change files.", + }), + }); + const nextTask = payload.task ?? null; + setTask(nextTask); + setEvents(nextTask?.events ?? []); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Model analysis could not start"); + } finally { + setRunning(false); + } + }; + return ( @@ -227,8 +266,39 @@ export default function HarnessPage() { value="Operator UI and model/chat surface" />
- The canary is intentionally credential-free and isolated. It proves the client path - without sending work to vLLM or changing your connected project. + The safe canary is credential-free. Model analysis uses the selected vLLM route in a + temporary isolated workspace with write tools disabled. +
+
+ + +
@@ -253,6 +323,21 @@ export default function HarnessPage() {

{task.summary ?? task.human_title}

+ {task.metadata?.integration ? ( +
+ + {task.metadata.integration.node} · {task.metadata.integration.model_id} + + {task.metadata.integration.runtime} + read-only + + workspace{" "} + {task.metadata.integration.connected_workspace_mutated + ? "changed" + : "unchanged"} + +
+ ) : null}
{(events ?? []).slice(-6).map((event, index) => (
Date: Mon, 27 Jul 2026 11:06:03 -0700 Subject: [PATCH 3/9] fix: prevent duplicate vllm analysis submissions --- frontend/src/features/harness/harness-page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/harness/harness-page.tsx b/frontend/src/features/harness/harness-page.tsx index 6f5e75181..54e9ee901 100644 --- a/frontend/src/features/harness/harness-page.tsx +++ b/frontend/src/features/harness/harness-page.tsx @@ -295,6 +295,7 @@ export default function HarnessPage() { size="sm" icon={} loading={running} + disabled={running} onClick={() => void runAnalysis()} > Run vLLM analysis From bae4f79ef642aaacc500a36f1f53df30ce294fae Mon Sep 17 00:00:00 2001 From: AI Supervisor Date: Mon, 27 Jul 2026 14:11:10 -0700 Subject: [PATCH 4/9] fix: complete local studio harness goal flow --- .../app/api/harness/proxy-to-harness.test.ts | 37 +++++++++++++++++++ .../src/app/api/harness/proxy-to-harness.ts | 24 ++++++++++-- .../src/features/harness/harness-page.tsx | 31 ++++++++++++---- 3 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/api/harness/proxy-to-harness.test.ts diff --git a/frontend/src/app/api/harness/proxy-to-harness.test.ts b/frontend/src/app/api/harness/proxy-to-harness.test.ts new file mode 100644 index 000000000..39d9535ed --- /dev/null +++ b/frontend/src/app/api/harness/proxy-to-harness.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { upstreamRequestHeaders } from "./proxy-to-harness"; + +describe("Local Studio Harness proxy headers", () => { + test("removes browser-origin metadata from the internal upstream request", () => { + const source = new Headers({ + accept: "application/json", + authorization: "Bearer test-token", + "content-type": "application/json", + origin: "http://127.0.0.1:4783", + referer: "http://127.0.0.1:4783/harness", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "x-request-id": "request-1", + }); + + const upstream = upstreamRequestHeaders(source); + + for (const name of [ + "origin", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + ]) { + assert.equal(upstream.get(name), null, `${name} must not cross the proxy boundary`); + } + assert.equal(upstream.get("authorization"), "Bearer test-token"); + assert.equal(upstream.get("content-type"), "application/json"); + assert.equal(upstream.get("referer"), "http://127.0.0.1:4783/harness"); + assert.equal(upstream.get("x-request-id"), "request-1"); + assert.equal(source.get("origin"), "http://127.0.0.1:4783"); + }); +}); diff --git a/frontend/src/app/api/harness/proxy-to-harness.ts b/frontend/src/app/api/harness/proxy-to-harness.ts index d380a0773..56ff0a5d3 100644 --- a/frontend/src/app/api/harness/proxy-to-harness.ts +++ b/frontend/src/app/api/harness/proxy-to-harness.ts @@ -1,6 +1,19 @@ import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body"; -const HOP_BY_HOP_REQUEST_HEADERS = ["host", "connection", "content-length", "accept-encoding"]; +const UPSTREAM_REQUEST_HEADERS_TO_REMOVE = [ + "host", + "connection", + "content-length", + "accept-encoding", + // The Harness server validates browser-origin requests against its own + // listener. These headers describe the browser-to-Local-Studio hop and must + // not be replayed on the trusted Local-Studio-to-Harness hop. + "origin", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", +]; const DEFAULT_HARNESS_URL = "http://127.0.0.1:8771"; export function harnessBaseUrl(): string { @@ -8,6 +21,12 @@ export function harnessBaseUrl(): string { return (raw || DEFAULT_HARNESS_URL).replace(/\/+$/, ""); } +export function upstreamRequestHeaders(requestHeaders: Headers): Headers { + const headers = new Headers(requestHeaders); + for (const name of UPSTREAM_REQUEST_HEADERS_TO_REMOVE) headers.delete(name); + return headers; +} + export async function proxyToHarness( request: Request, path: string[], @@ -16,8 +35,7 @@ export async function proxyToHarness( const targetPath = path.map((part) => encodeURIComponent(part)).join("/"); const sourceUrl = new URL(request.url); const target = `${harnessBaseUrl()}/v1/${targetPath}${sourceUrl.search}`; - const headers = new Headers(request.headers); - for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name); + const headers = upstreamRequestHeaders(request.headers); let body: ArrayBuffer | undefined; if (request.method !== "GET" && request.method !== "HEAD") { diff --git a/frontend/src/features/harness/harness-page.tsx b/frontend/src/features/harness/harness-page.tsx index 54e9ee901..97ba48e8f 100644 --- a/frontend/src/features/harness/harness-page.tsx +++ b/frontend/src/features/harness/harness-page.tsx @@ -41,7 +41,11 @@ type HarnessTask = { }; type RoutesResponse = { routes?: HarnessRoute[]; selection?: { policy?: string } }; -type TaskResponse = { task?: HarnessTask; current?: HarnessTask; events?: HarnessTask["events"] }; +type TaskResponse = Partial & { + task?: HarnessTask; + current?: HarnessTask; + events?: HarnessTask["events"]; +}; async function readJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, cache: "no-store" }); @@ -85,7 +89,7 @@ export default function HarnessPage() { ? `/api/harness/tasks/${encodeURIComponent(taskId)}` : "/api/harness/tasks/current"; const payload = await readJson(target); - const nextTask = payload.task ?? payload.current ?? null; + const nextTask = payload.task ?? (payload.id ? payload : (payload.current ?? null)); setTask(nextTask); if (nextTask?.id) { const eventPayload = await readJson( @@ -114,12 +118,23 @@ export default function HarnessPage() { useMountSubscription(() => { if (!task?.id || ["done", "stopped", "blocked"].includes(task.status ?? "")) return; - const timer = window.setTimeout(() => { - void loadTask(task.id).catch((nextError) => - setError(nextError instanceof Error ? nextError.message : "Task refresh failed"), - ); - }, 3000); - return () => window.clearTimeout(timer); + let stopped = false; + let timer: number | undefined; + const poll = () => { + timer = window.setTimeout(async () => { + try { + await loadTask(task.id!); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Task refresh failed"); + } + if (!stopped) poll(); + }, 3000); + }; + poll(); + return () => { + stopped = true; + if (timer !== undefined) window.clearTimeout(timer); + }; }, [loadTask, task?.id, task?.status]); const runCanary = async () => { From 4cd1e5d46f093ab99c495e964d98cb51799aacad Mon Sep 17 00:00:00 2001 From: AI Supervisor Date: Tue, 28 Jul 2026 00:08:32 -0700 Subject: [PATCH 5/9] feat: make Local Studio Harness goals usable --- .../api/harness/managed/[...path]/route.ts | 26 + .../api/harness/provider/[...path]/route.ts | 26 + .../app/api/harness/proxy-to-harness.test.ts | 34 +- .../src/app/api/harness/proxy-to-harness.ts | 69 +- .../harness/harness-page-model.test.ts | 319 +++++++ .../features/harness/harness-page-model.ts | 289 ++++++ .../src/features/harness/harness-page.tsx | 848 +++++++++++++++++- .../features/shell/left-sidebar-nav.test.ts | 1 + 8 files changed, 1554 insertions(+), 58 deletions(-) create mode 100644 frontend/src/app/api/harness/managed/[...path]/route.ts create mode 100644 frontend/src/app/api/harness/provider/[...path]/route.ts create mode 100644 frontend/src/features/harness/harness-page-model.test.ts create mode 100644 frontend/src/features/harness/harness-page-model.ts diff --git a/frontend/src/app/api/harness/managed/[...path]/route.ts b/frontend/src/app/api/harness/managed/[...path]/route.ts new file mode 100644 index 000000000..6086a32bc --- /dev/null +++ b/frontend/src/app/api/harness/managed/[...path]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyToManagedHarness } from "@/app/api/harness/proxy-to-harness"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const denied = requireApiAccess(request); + if (denied) return denied; + const { path } = await params; + return proxyToManagedHarness(request, path); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const denied = requireApiAccess(request); + if (denied) return denied; + const { path } = await params; + return proxyToManagedHarness(request, path); +} diff --git a/frontend/src/app/api/harness/provider/[...path]/route.ts b/frontend/src/app/api/harness/provider/[...path]/route.ts new file mode 100644 index 000000000..83ef3c6bd --- /dev/null +++ b/frontend/src/app/api/harness/provider/[...path]/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from "next/server"; +import { requireApiAccess } from "@/lib/auth/guard"; +import { proxyToProviderHarness } from "@/app/api/harness/proxy-to-harness"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const denied = requireApiAccess(request); + if (denied) return denied; + const { path } = await params; + return proxyToProviderHarness(request, path); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const denied = requireApiAccess(request); + if (denied) return denied; + const { path } = await params; + return proxyToProviderHarness(request, path); +} diff --git a/frontend/src/app/api/harness/proxy-to-harness.test.ts b/frontend/src/app/api/harness/proxy-to-harness.test.ts index 39d9535ed..11238b480 100644 --- a/frontend/src/app/api/harness/proxy-to-harness.test.ts +++ b/frontend/src/app/api/harness/proxy-to-harness.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { upstreamRequestHeaders } from "./proxy-to-harness"; +import { harnessTargetUrl, upstreamRequestHeaders } from "./proxy-to-harness"; describe("Local Studio Harness proxy headers", () => { test("removes browser-origin metadata from the internal upstream request", () => { @@ -34,4 +34,36 @@ describe("Local Studio Harness proxy headers", () => { assert.equal(upstream.get("x-request-id"), "request-1"); assert.equal(source.get("origin"), "http://127.0.0.1:4783"); }); + + test("still removes hop-by-hop transport headers", () => { + const source = new Headers({ + host: "127.0.0.1:4783", + connection: "keep-alive", + "content-length": "42", + "accept-encoding": "gzip, br", + "content-type": "application/json", + }); + + const upstream = upstreamRequestHeaders(source); + + for (const name of ["host", "connection", "content-length", "accept-encoding"]) { + assert.equal(upstream.get(name), null, `${name} must not cross the proxy boundary`); + } + assert.equal(upstream.get("content-type"), "application/json"); + }); + + test("keeps managed goals on the explicit /api namespace", () => { + assert.equal( + harnessTargetUrl(["tasks", "current", "stop"], "api"), + "http://127.0.0.1:8771/api/tasks/current/stop", + ); + assert.equal( + harnessTargetUrl(["tasks", "current"], "v1"), + "http://127.0.0.1:8771/v1/tasks/current", + ); + }); + + test("keeps provider-neutral goals on their isolated upstream", () => { + assert.equal(harnessTargetUrl(["tasks"], "api", "provider"), "http://127.0.0.1:8772/api/tasks"); + }); }); diff --git a/frontend/src/app/api/harness/proxy-to-harness.ts b/frontend/src/app/api/harness/proxy-to-harness.ts index 56ff0a5d3..e3cdd3d50 100644 --- a/frontend/src/app/api/harness/proxy-to-harness.ts +++ b/frontend/src/app/api/harness/proxy-to-harness.ts @@ -8,6 +8,12 @@ const UPSTREAM_REQUEST_HEADERS_TO_REMOVE = [ // The Harness server validates browser-origin requests against its own // listener. These headers describe the browser-to-Local-Studio hop and must // not be replayed on the trusted Local-Studio-to-Harness hop. + // Stripping them here is safe because the browser hop is already enforced + // before this route runs: src/proxy.ts applies evaluateRequestBoundary + // (host allowlist, cross-site rejection, Origin match, CSRF double-submit; + // see src/lib/security/request-boundary.ts and its tests) and the route + // handler re-checks the access token via requireApiAccess. Do not add a + // second origin gate here, and do not stop stripping these headers. "origin", "sec-fetch-dest", "sec-fetch-mode", @@ -15,10 +21,18 @@ const UPSTREAM_REQUEST_HEADERS_TO_REMOVE = [ "sec-fetch-user", ]; const DEFAULT_HARNESS_URL = "http://127.0.0.1:8771"; +const DEFAULT_PROVIDER_HARNESS_URL = "http://127.0.0.1:8772"; -export function harnessBaseUrl(): string { - const raw = process.env.LOCAL_STUDIO_HARNESS_URL?.trim(); - return (raw || DEFAULT_HARNESS_URL).replace(/\/+$/, ""); +export type HarnessTarget = "managed" | "provider"; + +export function harnessBaseUrl(target: HarnessTarget = "managed"): string { + const raw = ( + target === "provider" + ? process.env.LOCAL_STUDIO_PROVIDER_HARNESS_URL + : process.env.LOCAL_STUDIO_HARNESS_URL + )?.trim(); + const fallback = target === "provider" ? DEFAULT_PROVIDER_HARNESS_URL : DEFAULT_HARNESS_URL; + return (raw || fallback).replace(/\/+$/, ""); } export function upstreamRequestHeaders(requestHeaders: Headers): Headers { @@ -27,14 +41,24 @@ export function upstreamRequestHeaders(requestHeaders: Headers): Headers { return headers; } -export async function proxyToHarness( +export function harnessTargetUrl( + path: string[], + namespace: "v1" | "api" = "v1", + target: HarnessTarget = "managed", +): string { + const targetPath = path.map((part) => encodeURIComponent(part)).join("/"); + return `${harnessBaseUrl(target)}/${namespace}/${targetPath}`; +} + +async function proxyToHarnessNamespace( request: Request, path: string[], + namespace: "v1" | "api", + target: HarnessTarget, bodyLimitBytes = 256 * 1024, ): Promise { - const targetPath = path.map((part) => encodeURIComponent(part)).join("/"); const sourceUrl = new URL(request.url); - const target = `${harnessBaseUrl()}/v1/${targetPath}${sourceUrl.search}`; + const upstreamTarget = `${harnessTargetUrl(path, namespace, target)}${sourceUrl.search}`; const headers = upstreamRequestHeaders(request.headers); let body: ArrayBuffer | undefined; @@ -47,7 +71,7 @@ export async function proxyToHarness( let upstream: Response; try { - upstream = await fetch(target, { + upstream = await fetch(upstreamTarget, { method: request.method, headers, body, @@ -58,7 +82,7 @@ export async function proxyToHarness( if (request.signal.aborted) throw error; return Response.json( { - error: `agentic harness unreachable at ${harnessBaseUrl()}: ${ + error: `agentic harness unreachable at ${harnessBaseUrl(target)}: ${ error instanceof Error ? error.message : "fetch failed" }`, }, @@ -75,3 +99,32 @@ export async function proxyToHarness( headers: responseHeaders, }); } + +export function proxyToHarness( + request: Request, + path: string[], + bodyLimitBytes = 256 * 1024, +): Promise { + return proxyToHarnessNamespace(request, path, "v1", "managed", bodyLimitBytes); +} + +/** + * Proxy the managed local-goal API separately from the read-only integration + * API. Keeping the namespace explicit prevents a UI caller from accidentally + * turning a v1 canary endpoint into a privileged durable-goal action. + */ +export function proxyToManagedHarness( + request: Request, + path: string[], + bodyLimitBytes = 256 * 1024, +): Promise { + return proxyToHarnessNamespace(request, path, "api", "managed", bodyLimitBytes); +} + +export function proxyToProviderHarness( + request: Request, + path: string[], + bodyLimitBytes = 256 * 1024, +): Promise { + return proxyToHarnessNamespace(request, path, "api", "provider", bodyLimitBytes); +} diff --git a/frontend/src/features/harness/harness-page-model.test.ts b/frontend/src/features/harness/harness-page-model.test.ts new file mode 100644 index 000000000..7f67bcb5e --- /dev/null +++ b/frontend/src/features/harness/harness-page-model.test.ts @@ -0,0 +1,319 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + TERMINAL_TASK_STATUSES, + describeGoalOutcome, + goalStartBlocker, + isTerminalTaskStatus, + resolveTaskEnvelope, + stripGoalCommandPrefix, + startTaskPolling, +} from "./harness-page-model"; + +const runningTask = { id: "task-1", status: "working" }; + +describe("resolveTaskEnvelope", () => { + test("prefers the explicit task envelope over everything else", () => { + const resolved = resolveTaskEnvelope({ + task: runningTask, + current: { id: "task-2", status: "done" }, + id: "envelope-id", + status: "working", + }); + assert.equal(resolved?.id, "task-1"); + assert.equal(resolved?.status, "working"); + }); + + test("an envelope carrying both a top-level id and current resolves to current", () => { + // Regression: `{ id: "req-1", current: {...} }` used to be misread as the + // task itself because the old discriminator only checked `payload.id`. + const resolved = resolveTaskEnvelope({ + id: "req-1", + current: runningTask, + }); + assert.equal(resolved?.id, "task-1"); + assert.equal(resolved?.status, "working"); + }); + + test("current still wins even when the envelope itself is task-shaped", () => { + const resolved = resolveTaskEnvelope({ + id: "req-1", + status: "working", + current: runningTask, + }); + assert.equal(resolved?.id, "task-1"); + }); + + test("an invalid explicit envelope value falls through instead of masking the task", () => { + assert.equal(resolveTaskEnvelope({ task: {}, current: runningTask })?.id, "task-1"); + assert.equal( + resolveTaskEnvelope({ task: { id: "half-formed" }, current: runningTask })?.id, + "task-1", + ); + // The idle shape: tasks/current answers { current: null, tasks: [...] }. + assert.equal(resolveTaskEnvelope({ current: null }), null); + }); + + test("returns null when every candidate is malformed", () => { + assert.equal(resolveTaskEnvelope({ task: {}, current: { id: "", status: "" } }), null); + assert.equal(resolveTaskEnvelope({ task: null, current: null, id: "req-9" }), null); + }); + + test("accepts a bare payload only when it has a valid task shape", () => { + assert.equal(resolveTaskEnvelope(runningTask)?.id, "task-1"); + // id without status is not a task. + assert.equal(resolveTaskEnvelope({ id: "req-1" }), null); + // status without id is not a task. + assert.equal(resolveTaskEnvelope({ status: "working" }), null); + // Empty ids/statuses do not count. + assert.equal(resolveTaskEnvelope({ id: "", status: "" }), null); + // The events envelope ({ task_id, events }) has neither key. + assert.equal(resolveTaskEnvelope({ events: [] }), null); + assert.equal(resolveTaskEnvelope({}), null); + }); +}); + +describe("isTerminalTaskStatus", () => { + test("matches the Harness durable terminal set exactly", () => { + assert.deepEqual([...TERMINAL_TASK_STATUSES].sort(), ["blocked", "done", "failed", "stopped"]); + for (const status of TERMINAL_TASK_STATUSES) { + assert.equal(isTerminalTaskStatus(status), true); + } + }); + + test("active, unknown, and missing statuses keep polling", () => { + for (const status of ["working", "checking", "starting", "queued", "", undefined]) { + assert.equal(isTerminalTaskStatus(status), false, `${String(status)} must not be terminal`); + } + }); +}); + +describe("goal prompt and outcome helpers", () => { + test("accepts ordinary prose and strips only a standalone /goal prefix", () => { + assert.equal( + stripGoalCommandPrefix(" Review the provider flow "), + "Review the provider flow", + ); + assert.equal(stripGoalCommandPrefix("/goal Fix the prompt flow"), "Fix the prompt flow"); + assert.equal(stripGoalCommandPrefix("/goalkeeper review"), "/goalkeeper review"); + assert.equal(stripGoalCommandPrefix(" /goal "), ""); + }); + + test("explains why a goal cannot start instead of leaving a dead button", () => { + assert.match( + goalStartBlocker({ + goal: "", + backend: "managed", + providerConfigured: true, + setupLoading: false, + }) ?? "", + /Type a goal first/, + ); + assert.match( + goalStartBlocker({ + goal: "Review the provider", + backend: "provider", + providerConfigured: false, + setupLoading: false, + }) ?? "", + /endpoint and model/, + ); + assert.equal( + goalStartBlocker({ + goal: "Review the provider", + backend: "managed", + providerConfigured: true, + setupLoading: false, + }), + null, + ); + }); + + test("an active shared-workspace goal explains why a new goal cannot start", () => { + const blocker = goalStartBlocker({ + goal: "Review the provider", + backend: "managed", + providerConfigured: true, + setupLoading: false, + activeTaskStatus: "working", + }); + assert.match(String(blocker), /another goal is already running/i); + assert.match(String(blocker), /shared workspace/i); + }); + + test("a terminal current task does not block a new goal", () => { + assert.equal( + goalStartBlocker({ + goal: "Review the provider", + backend: "managed", + providerConfigured: true, + setupLoading: false, + activeTaskStatus: "done", + }), + null, + ); + }); + + test("does not call a done task verified when it has no recorded checks", () => { + const outcome = describeGoalOutcome({ id: "task-1", status: "done" }); + assert.equal(outcome?.state, "unverified"); + assert.match(outcome?.headline ?? "", /without verification/); + }); + + test("treats a provider failure as terminal and actionable", () => { + const outcome = describeGoalOutcome({ + id: "task-2", + status: "failed", + summary: "Provider stopped with evidence.", + }); + assert.equal(outcome?.state, "blocked"); + assert.match(outcome?.headline ?? "", /failed/i); + assert.match(outcome?.detail ?? "", /Provider stopped/); + }); +}); + +type Deferred = { + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; +}; + +function deferred(): Deferred { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function manualTimers() { + const pending: Array<{ id: number; fn: () => void }> = []; + let nextId = 1; + return { + schedule(fn: () => void, _ms: number): number { + const id = nextId++; + pending.push({ id, fn }); + return id; + }, + clearSchedule(id: number): void { + const index = pending.findIndex((timer) => timer.id === id); + if (index >= 0) pending.splice(index, 1); + }, + fire(): void { + const next = pending.shift(); + assert.ok(next, "expected a pending timer to fire"); + next.fn(); + }, + get count(): number { + return pending.length; + }, + }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("startTaskPolling", () => { + test("serializes polls and re-arms only after the load settles", async () => { + const timers = manualTimers(); + const gate = deferred(); + const seen: AbortSignal[] = []; + const stop = startTaskPolling({ + intervalMs: 3000, + load: (signal) => { + seen.push(signal); + return gate.promise; + }, + onError: () => assert.fail("no error expected"), + schedule: timers.schedule, + clearSchedule: timers.clearSchedule, + }); + + assert.equal(timers.count, 1, "arms an initial timer"); + timers.fire(); + assert.equal(seen.length, 1); + assert.equal(timers.count, 0, "must not re-arm while a load is in flight"); + + gate.resolve(); + await settle(); + assert.equal(timers.count, 1, "re-arms after the load settles"); + + stop(); + assert.equal(timers.count, 0, "stop clears the pending timer"); + }); + + test("reports transient errors and keeps polling", async () => { + const timers = manualTimers(); + const errors: unknown[] = []; + let attempts = 0; + const stop = startTaskPolling({ + intervalMs: 3000, + load: () => { + attempts += 1; + return Promise.reject(new Error(`boom ${attempts}`)); + }, + onError: (error) => errors.push(error), + schedule: timers.schedule, + clearSchedule: timers.clearSchedule, + }); + + timers.fire(); + await settle(); + assert.equal(errors.length, 1); + assert.equal(timers.count, 1, "keeps polling after an error"); + + timers.fire(); + await settle(); + assert.equal(errors.length, 2); + + stop(); + assert.equal(timers.count, 0); + }); + + test("stop aborts the in-flight load and suppresses its error", async () => { + const timers = manualTimers(); + const gate = deferred(); + const seen: AbortSignal[] = []; + const errors: unknown[] = []; + const stop = startTaskPolling({ + intervalMs: 3000, + load: (signal) => { + seen.push(signal); + return gate.promise; + }, + onError: (error) => errors.push(error), + schedule: timers.schedule, + clearSchedule: timers.clearSchedule, + }); + + timers.fire(); + assert.equal(seen[0]?.aborted, false); + + stop(); + assert.equal(seen[0]?.aborted, true, "stop must abort the in-flight signal"); + + gate.reject(new DOMException("The operation was aborted.", "AbortError")); + await settle(); + assert.deepEqual(errors, [], "aborted loads must not surface as errors"); + assert.equal(timers.count, 0, "no re-arm after stop"); + }); + + test("a load that resolves after stop cannot re-arm the poller", async () => { + const timers = manualTimers(); + const gate = deferred(); + const stop = startTaskPolling({ + intervalMs: 3000, + load: () => gate.promise, + onError: () => assert.fail("no error expected"), + schedule: timers.schedule, + clearSchedule: timers.clearSchedule, + }); + + timers.fire(); + stop(); + gate.resolve(); + await settle(); + assert.equal(timers.count, 0, "stale completion must not restart polling"); + }); +}); diff --git a/frontend/src/features/harness/harness-page-model.ts b/frontend/src/features/harness/harness-page-model.ts new file mode 100644 index 000000000..787220591 --- /dev/null +++ b/frontend/src/features/harness/harness-page-model.ts @@ -0,0 +1,289 @@ +// Pure model logic for the Harness operator surface. Kept framework-free so the +// envelope discrimination, terminal-status contract, and polling lifecycle can +// be unit tested without React. + +export type HarnessTask = { + id?: string; + status?: string; + status_label?: string; + summary?: string; + human_title?: string; + artifacts?: Array<{ name?: string; path?: string }>; + events?: Array<{ seq?: number; summary?: string; checkpoint?: string }>; + metadata?: { + demo?: { enabled?: boolean; model_used?: boolean; workspace?: string }; + integration?: { + kind?: string; + route_id?: string; + model_id?: string; + node?: string; + runtime?: string; + model_used?: boolean; + connected_workspace_mutated?: boolean; + }; + }; +}; + +// The Harness GUI server answers with three envelope shapes: +// tasks/current -> { current, tasks } +// tasks/{id} -> { api_version, task, owner } +// tasks/{id}/events -> { api_version, task_id, events } +// Older builds returned the bare task object. TaskResponse tolerates all of +// them; resolveTaskEnvelope decides which one we are looking at. +export type TaskResponse = Partial & { + task?: HarnessTask | null; + current?: HarnessTask | null; + events?: HarnessTask["events"]; +}; + +export type ManagedGoalTask = HarnessTask & { + objective?: string; + mode?: string; + execution_profile?: string; + needs_human?: boolean; + review_status?: string; + changed_files?: string[]; + verification?: string[]; + progress?: { label?: string; percent?: number; determinate?: boolean }; + current?: { checkpoint?: string; current_subgoal?: string; cycle?: number }; + readiness_gate?: { + can_start?: boolean; + can_queue?: boolean; + state?: string; + label?: string; + next_action?: string; + summary?: string; + requires_review?: boolean; + }; + advanced_details?: { + payload?: { events?: HarnessTask["events"] }; + last_run?: { run_dir?: string; prompt_path?: string }; + }; +}; + +export type ManagedTaskResponse = Partial & { + task?: ManagedGoalTask | null; + current?: ManagedGoalTask | null; + events?: ManagedGoalTask["events"]; +}; + +function isTaskShaped( + candidate: Partial, +): candidate is Partial & { id: string; status: string } { + return ( + typeof candidate.id === "string" && + candidate.id.length > 0 && + typeof candidate.status === "string" && + candidate.status.length > 0 + ); +} + +/** Explicit `task` / `current` envelopes win, in that order, but only when the + * value actually looks like a task (non-empty id AND status). Every valid + * Harness response serializes both fields, so this rejects only malformed + * values — `current: null` while idle, `task: {}` from a broken upstream — + * and lets them fall through to the next candidate instead of masking it. + * The top-level payload is checked last so an envelope that happens to carry + * a top-level id (e.g. a request id next to `current`) can never shadow the + * real task. */ +export function resolveTaskEnvelope(payload: TaskResponse): HarnessTask | null { + for (const candidate of [payload.task, payload.current, payload]) { + if (candidate && isTaskShaped(candidate)) return candidate; + } + return null; +} + +// Task statuses come from the managed and provider Harness adapters: +// done | stopped | blocked | failed | checking | starting | working. The +// durable terminal set includes provider failures because a failed worker run +// is complete evidence and must not block a fresh goal. +// Unknown/future statuses are treated as active so we keep polling — the safe +// default for states we have never seen. +export const TERMINAL_TASK_STATUSES: ReadonlySet = new Set([ + "done", + "stopped", + "blocked", + "failed", +]); + +export function isTerminalTaskStatus(status: string | undefined): boolean { + return status !== undefined && TERMINAL_TASK_STATUSES.has(status); +} + +export type GoalBackendKind = "managed" | "provider"; + +/** The prompt box accepts an ordinary sentence; `/goal` is an optional + * Codex-style prefix. Stripping it here keeps the submit path and its tests + * in agreement about what counts as an empty objective. */ +export function stripGoalCommandPrefix(raw: string): string { + return raw + .trim() + .replace(/^\/goal(?:\s+|$)/i, "") + .trim(); +} + +export type GoalStartInput = { + goal: string; + backend: GoalBackendKind; + /** `setup.configured` for the currently selected backend. Callers must pass + * `false` while the setup payload is unknown so the gate fails closed. */ + providerConfigured: boolean; + setupLoading: boolean; + /** Current task status for the selected shared workspace, when known. */ + activeTaskStatus?: string; +}; + +/** Why a goal cannot start yet, phrased for a first-time user, or null when it + * can. Returning a reason (instead of only disabling the button) is what makes + * the empty-prompt and unconfigured-provider cases explainable rather than a + * dead control. Provider copy stays generic: no host, path, or node names. */ +export function goalStartBlocker(input: GoalStartInput): string | null { + if (input.setupLoading) return "Loading goal setup…"; + const objective = stripGoalCommandPrefix(input.goal); + if (!objective) { + return input.goal.trim() + ? "Add an objective after /goal, for example: /goal summarise the open issues." + : "Type a goal first. You can start it with /goal."; + } + if (input.backend === "provider" && !input.providerConfigured) { + return "Add an endpoint and model under Model provider, then save, before starting a goal."; + } + if ( + input.activeTaskStatus && + !isTerminalTaskStatus(input.activeTaskStatus) && + input.activeTaskStatus !== "needs_review" && + input.activeTaskStatus !== "ready" + ) { + return "Another goal is already running in this shared workspace. Continue or stop it before starting a new one."; + } + return null; +} + +export type GoalOutcomeTone = "default" | "good" | "warning" | "danger"; + +export type GoalOutcome = { + state: "running" | "needs_review" | "stopped" | "blocked" | "complete" | "unverified"; + tone: GoalOutcomeTone; + headline: string; + detail: string; +}; + +/** Plain-language terminal state for the goal card. + * + * Completion honesty: "verified" is only claimed when the Harness actually + * recorded verification checks on the task. A `done` task with no checks is + * reported as finished-but-unverified, never as verified, so UI prose can + * never outrun the evidence contract. Check counts are described as + * "recorded" because the task payload carries the checks, not their verdicts. */ +export function describeGoalOutcome(task: ManagedGoalTask | null): GoalOutcome | null { + if (!task?.id) return null; + const status = task.status ?? ""; + const summary = task.summary?.trim(); + const checks = task.verification?.length ?? 0; + const files = task.changed_files?.length ?? 0; + + if (status === "done" || status === "complete") { + if (checks === 0) { + return { + state: "unverified", + tone: "warning", + headline: "Finished without verification evidence", + detail: + summary ?? + "The harness recorded no verification checks for this goal, so it is not a verified completion.", + }; + } + return { + state: "complete", + tone: "good", + headline: "Goal complete with recorded evidence", + detail: `${checks} verification check${checks === 1 ? "" : "s"} recorded · ${files} changed file${ + files === 1 ? "" : "s" + }.`, + }; + } + if (status === "stopped") { + return { + state: "stopped", + tone: "warning", + headline: "Goal stopped", + detail: summary ?? "This goal was stopped before it finished. Start a new goal to continue.", + }; + } + if (status === "blocked") { + return { + state: "blocked", + tone: "danger", + headline: "Goal blocked", + detail: + summary ?? "The harness could not continue. Add feedback below and continue the goal.", + }; + } + if (status === "failed") { + return { + state: "blocked", + tone: "danger", + headline: "Goal failed", + detail: + summary ?? "The provider could not complete this goal. Start a new goal to try again.", + }; + } + if (status === "needs_review") { + return { + state: "needs_review", + tone: "warning", + headline: "Waiting for your review", + detail: summary ?? "The harness finished a cycle and needs your decision before continuing.", + }; + } + return { + state: "running", + tone: "default", + headline: task.progress?.label ?? task.current?.checkpoint ?? "Working", + detail: summary ?? "The harness is working on this goal.", + }; +} + +export type TaskPollerOptions = { + intervalMs: number; + /** Load one refresh. Receives the poller's AbortSignal; implementations must + * pass it to fetch and stop touching state once it is aborted. */ + load: (signal: AbortSignal) => Promise; + /** Called for load failures, except aborts caused by stopping the poller. */ + onError: (error: unknown) => void; + /** Injectable timers for tests. Default to window timers in the browser. */ + schedule?: (fn: () => void, ms: number) => number; + clearSchedule?: (id: number) => void; +}; + +/** Serialized task polling: each cycle waits for the previous load to settle + * before arming the next timer, so requests never overlap; a failed load + * reports the error and keeps polling. The returned stop function clears any + * pending timer, aborts an in-flight load, and guarantees no re-arm and no + * error report afterwards. */ +export function startTaskPolling(options: TaskPollerOptions): () => void { + const schedule = options.schedule ?? ((fn, ms) => window.setTimeout(fn, ms)); + const clearSchedule = options.clearSchedule ?? ((id) => window.clearTimeout(id)); + const controller = new AbortController(); + let timer: number | undefined; + let stopped = false; + + const tick = async (): Promise => { + try { + await options.load(controller.signal); + } catch (error) { + if (!stopped && !controller.signal.aborted) options.onError(error); + } + if (!stopped) arm(); + }; + const arm = (): void => { + timer = schedule(() => void tick(), options.intervalMs); + }; + arm(); + + return () => { + stopped = true; + controller.abort(); + if (timer !== undefined) clearSchedule(timer); + }; +} diff --git a/frontend/src/features/harness/harness-page.tsx b/frontend/src/features/harness/harness-page.tsx index 97ba48e8f..bbdad1afc 100644 --- a/frontend/src/features/harness/harness-page.tsx +++ b/frontend/src/features/harness/harness-page.tsx @@ -3,6 +3,18 @@ import { useCallback, useState, type ReactNode } from "react"; import { AppPage, Button, Card, ErrorBox, PageContainer, PageHeader, StatusPill } from "@/ui"; import { useMountSubscription } from "@/hooks/use-mount-subscription"; +import { + describeGoalOutcome, + goalStartBlocker, + isTerminalTaskStatus, + resolveTaskEnvelope, + startTaskPolling, + stripGoalCommandPrefix, + type HarnessTask, + type ManagedGoalTask, + type ManagedTaskResponse, + type TaskResponse, +} from "./harness-page-model"; import { GitBranch, Play, RefreshCw, ShieldCheck } from "@/ui/icon-registry"; type HarnessRoute = { @@ -18,35 +30,40 @@ type HarnessRoute = { eligible_for: string[]; }; -type HarnessTask = { - id?: string; - status?: string; - status_label?: string; +type RoutesResponse = { routes?: HarnessRoute[]; selection?: { policy?: string } }; +type ManagedMode = { + key: string; + label: string; + best_for?: string; + caution?: string; +}; +type ManagedProfile = { + key: string; + label: string; summary?: string; - human_title?: string; - artifacts?: Array<{ name?: string; path?: string }>; - events?: Array<{ seq?: number; summary?: string; checkpoint?: string }>; - metadata?: { - demo?: { enabled?: boolean; model_used?: boolean; workspace?: string }; - integration?: { - kind?: string; - route_id?: string; - model_id?: string; - node?: string; - runtime?: string; - model_used?: boolean; - connected_workspace_mutated?: boolean; - }; - }; + caution?: string; }; - -type RoutesResponse = { routes?: HarnessRoute[]; selection?: { policy?: string } }; -type TaskResponse = Partial & { - task?: HarnessTask; - current?: HarnessTask; - events?: HarnessTask["events"]; +type ManagedModesResponse = { + kind?: string; + default?: string; + default_execution_profile?: string; + modes?: ManagedMode[]; + execution_profiles?: ManagedProfile[]; }; - +type ManagedSetupResponse = { + suggested_check?: string; + verification_command?: string; + workspace?: string; + worker?: { label?: string; type?: string }; + configured?: boolean; + provider?: { + endpoint?: string; + model?: string; + api_key_env?: string; + data_location?: string; + }; +}; +type GoalBackend = "managed" | "provider"; async function readJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, cache: "no-store" }); const payload = (await response.json().catch(() => ({}))) as { error?: string } & T; @@ -56,8 +73,10 @@ async function readJson(url: string, init?: RequestInit): Promise { function toneForStatus(status: string): "default" | "good" | "warning" | "danger" { if (status === "ready" || status === "done" || status === "complete") return "good"; - if (status === "unavailable" || status === "blocked") return "danger"; - if (status === "degraded" || status === "needs_review") return "warning"; + if (status === "unavailable" || status === "blocked" || status === "failed") return "danger"; + // "stopped" is a real interruption, not a neutral resting state: showing it in + // the default tone made a halted goal look the same as a running one. + if (status === "degraded" || status === "needs_review" || status === "stopped") return "warning"; return "default"; } @@ -66,6 +85,19 @@ function formatContext(tokens: number): string { return `${tokens} context`; } +function isManagedGoalTerminal(task: ManagedGoalTask | null): boolean { + return ( + !task?.id || + isTerminalTaskStatus(task.status) || + task.status === "needs_review" || + task.status === "ready" + ); +} + +function managedTaskFromResponse(payload: ManagedTaskResponse): ManagedGoalTask | null { + return resolveTaskEnvelope(payload as TaskResponse) as ManagedGoalTask | null; +} + // The page coordinates route, task, event, and evidence state in one operator surface. // eslint-disable-next-line complexity export default function HarnessPage() { @@ -77,6 +109,27 @@ export default function HarnessPage() { const [loading, setLoading] = useState(true); const [running, setRunning] = useState(false); const [error, setError] = useState(""); + const [goal, setGoal] = useState(""); + const [goalBackend, setGoalBackend] = useState("managed"); + const [goalMode, setGoalMode] = useState("local"); + const [goalProfile, setGoalProfile] = useState("qwen-primary"); + const [goalFeedback, setGoalFeedback] = useState(""); + const [managedTask, setManagedTask] = useState(null); + const [managedEvents, setManagedEvents] = useState>([]); + const [managedModes, setManagedModes] = useState([]); + const [managedProfiles, setManagedProfiles] = useState([]); + const [managedSetup, setManagedSetup] = useState(null); + const [goalLoading, setGoalLoading] = useState(true); + const [goalBusy, setGoalBusy] = useState(false); + const [goalError, setGoalError] = useState(""); + const [providerEndpoint, setProviderEndpoint] = useState(""); + const [providerModel, setProviderModel] = useState(""); + const [providerApiKeyEnv, setProviderApiKeyEnv] = useState(""); + const [providerApiKey, setProviderApiKey] = useState(""); + const [providerVerificationCommand, setProviderVerificationCommand] = useState(""); + const [providerRemoteConfirmed, setProviderRemoteConfirmed] = useState(false); + const [providerBusy, setProviderBusy] = useState(false); + const [providerMessage, setProviderMessage] = useState(""); const loadRoutes = useCallback(async () => { const payload = await readJson("/api/harness/routes"); @@ -84,23 +137,88 @@ export default function HarnessPage() { setSelectionPolicy(payload.selection?.policy ?? "harness_decides"); }, []); - const loadTask = useCallback(async (taskId?: string) => { + const loadTask = useCallback(async (taskId?: string, signal?: AbortSignal) => { const target = taskId ? `/api/harness/tasks/${encodeURIComponent(taskId)}` : "/api/harness/tasks/current"; - const payload = await readJson(target); - const nextTask = payload.task ?? (payload.id ? payload : (payload.current ?? null)); + const payload = await readJson(target, { signal }); + if (signal?.aborted) return; + const nextTask = resolveTaskEnvelope(payload); setTask(nextTask); if (nextTask?.id) { const eventPayload = await readJson( `/api/harness/tasks/${encodeURIComponent(nextTask.id)}/events?after=0`, + { signal }, ); + if (signal?.aborted) return; setEvents(eventPayload.events ?? nextTask.events ?? []); } else { setEvents([]); } }, []); + /** Task + events only. This is the poll payload: `modes` and `setup` do not + * change while a goal runs, so re-fetching them every interval was three + * extra round trips per tick for state that never moved. */ + const loadManagedTask = useCallback( + async (signal?: AbortSignal, backend: GoalBackend = goalBackend) => { + const prefix = backend === "provider" ? "/api/harness/provider" : "/api/harness/managed"; + const taskPayload = await readJson(`${prefix}/tasks/current`, { + signal, + }); + if (signal?.aborted) return; + const nextTask = managedTaskFromResponse(taskPayload); + setManagedTask(nextTask); + if (nextTask?.id) { + const eventPayload = await readJson(`${prefix}/tasks/current/events`, { + signal, + }); + if (signal?.aborted) return; + setManagedEvents(eventPayload.events ?? nextTask.events ?? []); + } else { + setManagedEvents(nextTask?.events ?? []); + } + }, + [goalBackend], + ); + + /** Full load: setup/modes plus the current task. Used on mount and whenever + * the selected goal engine changes, not on the polling interval. */ + const loadManaged = useCallback( + async (signal?: AbortSignal, backend: GoalBackend = goalBackend) => { + const prefix = backend === "provider" ? "/api/harness/provider" : "/api/harness/managed"; + const [modesPayload, setupPayload] = await Promise.all([ + readJson(`${prefix}/modes`, { signal }), + readJson(`${prefix}/setup`, { signal }), + // Task and events stay in the same parallel batch, so first paint is no + // slower than before the poll payload was split out. + loadManagedTask(signal, backend), + ]); + if (signal?.aborted) return; + setManagedModes(modesPayload.modes ?? []); + setManagedProfiles(modesPayload.execution_profiles ?? []); + setManagedSetup(setupPayload); + const availableModes = modesPayload.modes ?? []; + const fallbackMode = backend === "provider" ? (modesPayload.default ?? "plan") : "local"; + setGoalMode((current) => + availableModes.some((mode) => mode.key === current) ? current : fallbackMode, + ); + if (backend === "provider") { + // Switching into the provider lane must reflect that lane's saved + // setup, not stale values left in the form by a previous selection. + // verification_command is the effective persisted check; the + // detected suggestion is only the first-run fallback. + setProviderEndpoint(setupPayload.provider?.endpoint || ""); + setProviderModel(setupPayload.provider?.model || ""); + setProviderApiKeyEnv(setupPayload.provider?.api_key_env || ""); + setProviderVerificationCommand( + setupPayload.verification_command || setupPayload.suggested_check || "", + ); + } + }, + [goalBackend, loadManagedTask], + ); + const refresh = useCallback(async () => { setError(""); try { @@ -117,26 +235,166 @@ export default function HarnessPage() { }, [refresh]); useMountSubscription(() => { - if (!task?.id || ["done", "stopped", "blocked"].includes(task.status ?? "")) return; - let stopped = false; - let timer: number | undefined; - const poll = () => { - timer = window.setTimeout(async () => { - try { - await loadTask(task.id!); - } catch (nextError) { - setError(nextError instanceof Error ? nextError.message : "Task refresh failed"); - } - if (!stopped) poll(); - }, 3000); - }; - poll(); - return () => { - stopped = true; - if (timer !== undefined) window.clearTimeout(timer); - }; + void loadManaged() + .catch((nextError) => + setGoalError(nextError instanceof Error ? nextError.message : "Managed goal unavailable"), + ) + .finally(() => setGoalLoading(false)); + }, [loadManaged]); + + useMountSubscription(() => { + if (!task?.id || isTerminalTaskStatus(task.status)) return; + const taskId = task.id; + return startTaskPolling({ + intervalMs: 3000, + load: (signal) => loadTask(taskId, signal), + onError: (nextError) => + setError(nextError instanceof Error ? nextError.message : "Task refresh failed"), + }); }, [loadTask, task?.id, task?.status]); + useMountSubscription(() => { + if (isManagedGoalTerminal(managedTask)) return; + return startTaskPolling({ + intervalMs: 3000, + load: (signal) => loadManagedTask(signal), + onError: (nextError) => + setGoalError(nextError instanceof Error ? nextError.message : "Goal refresh failed"), + }); + }, [loadManagedTask, managedTask?.id, managedTask?.status]); + + const goalApiPrefix = + goalBackend === "provider" ? "/api/harness/provider" : "/api/harness/managed"; + + const startGoal = async () => { + // Validate on submit rather than only disabling the button, so a new user is + // told why nothing happened instead of facing an inert control. + const blocker = goalStartBlocker({ + goal, + backend: goalBackend, + providerConfigured: managedSetup?.configured === true, + setupLoading: goalLoading, + activeTaskStatus: managedTask?.status, + }); + if (blocker) { + setGoalError(blocker); + return; + } + const objective = stripGoalCommandPrefix(goal); + setGoalBusy(true); + setGoalError(""); + try { + const payload = await readJson(`${goalApiPrefix}/tasks`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + goalBackend === "provider" + ? { objective, strategy: goalMode } + : { objective, mode: goalMode, execution_profile: goalProfile }, + ), + }); + const nextTask = managedTaskFromResponse(payload); + setManagedTask(nextTask); + setManagedEvents(nextTask?.events ?? []); + if (nextTask?.status === "blocked" || nextTask?.status === "needs_review") { + setGoalError(nextTask.summary ?? "The goal needs attention before it can continue."); + } + } catch (nextError) { + setGoalError(nextError instanceof Error ? nextError.message : "Goal could not start"); + } finally { + setGoalBusy(false); + } + }; + + const runManagedAction = async (action: "stop" | "continue" | "accept") => { + setGoalBusy(true); + setGoalError(""); + try { + const payload = await readJson( + `${goalApiPrefix}/tasks/current/${action}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(action === "continue" ? { feedback: goalFeedback.trim() } : {}), + }, + ); + const nextTask = managedTaskFromResponse(payload); + setManagedTask(nextTask); + setManagedEvents(nextTask?.events ?? []); + if (action === "continue") setGoalFeedback(""); + } catch (nextError) { + setGoalError(nextError instanceof Error ? nextError.message : `Goal ${action} failed`); + } finally { + setGoalBusy(false); + } + }; + + const configureProvider = async (testOnly: boolean) => { + if (!providerEndpoint.trim() || !providerModel.trim()) { + setProviderMessage(""); + setGoalError( + "Enter both an OpenAI-compatible endpoint and a model ID before testing or saving.", + ); + return; + } + setProviderBusy(true); + setProviderMessage(""); + setGoalError(""); + try { + const payload = { + execution: providerEndpoint.trim().startsWith("https://") ? "cloud_model" : "local_model", + endpoint: providerEndpoint.trim(), + model: providerModel.trim(), + api_key_env: providerApiKeyEnv.trim(), + api_key: providerApiKey.trim(), + verification_command: providerVerificationCommand.trim(), + confirm_remote_data: providerRemoteConfirmed, + }; + const response = await readJson( + `${goalApiPrefix}/setup${testOnly ? "/test" : ""}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!testOnly) { + setManagedSetup(response); + setProviderApiKey(""); + } + setProviderMessage(testOnly ? "Connection test passed." : "Provider saved."); + } catch (nextError) { + setProviderMessage(""); + setGoalError(nextError instanceof Error ? nextError.message : "Provider setup failed"); + } finally { + setProviderBusy(false); + } + }; + + const node1GoalMode: ManagedMode = { + ...(managedModes.find((mode) => mode.key === "local") ?? { + key: "local", + label: "Managed goal loop", + best_for: "A durable goal executed by the configured managed worker.", + }), + label: "Managed goal loop", + best_for: "Runs through the configured managed worker and keeps durable task state.", + }; + const providerModeOptions = managedModes.length ? managedModes : []; + const selectedMode = + goalBackend === "provider" + ? (providerModeOptions.find((mode) => mode.key === goalMode) ?? providerModeOptions[0]) + : node1GoalMode; + + const startBlocker = goalStartBlocker({ + goal, + backend: goalBackend, + providerConfigured: managedSetup?.configured === true, + setupLoading: goalLoading, + activeTaskStatus: managedTask?.status, + }); + const goalOutcome = describeGoalOutcome(managedTask); + const runCanary = async () => { setRunning(true); setError(""); @@ -217,6 +475,498 @@ export default function HarnessPage() { {error ? {error} : null} + +
+
+ +