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/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 new file mode 100644 index 000000000..45bf1b055 --- /dev/null +++ b/frontend/src/app/api/harness/proxy-to-harness.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { + downstreamResponseHeaders, + harnessTargetUrl, + upstreamRequestHeaders, +} from "./proxy-to-harness"; + +describe("Local Studio Harness proxy headers", () => { + test("forwards only protocol headers required by the Harness API", () => { + const source = new Headers({ + accept: "application/json", + authorization: "Bearer test-token", + cookie: "local_studio_token=cookie-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-local-studio-csrf": "csrf-token", + "x-local-studio-token": "header-token", + "x-request-id": "request-1", + }); + + const upstream = upstreamRequestHeaders(source); + + for (const name of [ + "authorization", + "cookie", + "origin", + "referer", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-user", + "x-local-studio-csrf", + "x-local-studio-token", + ]) { + assert.equal(upstream.get(name), null, `${name} must not cross the proxy boundary`); + } + assert.equal(upstream.get("accept"), "application/json"); + assert.equal(upstream.get("content-type"), "application/json"); + 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("does not let the Harness set Local Studio cookies", () => { + const downstream = downstreamResponseHeaders( + new Headers({ + "content-type": "application/json", + "set-cookie": "local_studio_token=attacker-controlled", + "x-request-id": "request-2", + }), + ); + + assert.equal(downstream.get("set-cookie"), null); + assert.equal(downstream.get("content-type"), "application/json"); + assert.equal(downstream.get("x-request-id"), "request-2"); + }); + + test("rejects dot segments before URL normalization can escape the API namespace", () => { + assert.throws(() => harnessTargetUrl(["..", "admin"], "api"), /dot segments/); + assert.throws(() => harnessTargetUrl(["%2e%2e", "admin"], "api"), /dot segments/); + }); + + 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 new file mode 100644 index 000000000..3c9fb7504 --- /dev/null +++ b/frontend/src/app/api/harness/proxy-to-harness.ts @@ -0,0 +1,155 @@ +import { readRequestBytesWithinLimit } from "@shared/agent/agent-turn-body"; + +const UPSTREAM_REQUEST_HEADER_ALLOWLIST = [ + "accept", + "content-type", + "if-match", + "if-none-match", + "last-event-id", + "x-request-id", +]; +const DOWNSTREAM_RESPONSE_HEADER_ALLOWLIST = [ + "cache-control", + "content-type", + "etag", + "last-modified", + "retry-after", + "x-request-id", +]; +const DEFAULT_HARNESS_URL = "http://127.0.0.1:8771"; +const DEFAULT_PROVIDER_HARNESS_URL = "http://127.0.0.1:8772"; + +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 { + const headers = new Headers(); + for (const name of UPSTREAM_REQUEST_HEADER_ALLOWLIST) { + const value = requestHeaders.get(name); + if (value !== null) headers.set(name, value); + } + return headers; +} + +export function downstreamResponseHeaders(upstreamHeaders: Headers): Headers { + const headers = new Headers(); + for (const name of DOWNSTREAM_RESPONSE_HEADER_ALLOWLIST) { + const value = upstreamHeaders.get(name); + if (value !== null) headers.set(name, value); + } + return headers; +} + +function harnessPathSegment(part: string): string { + let decoded = part; + try { + decoded = decodeURIComponent(part); + } catch { + throw new TypeError("Harness path contains invalid encoding"); + } + if (decoded === "." || decoded === "..") { + throw new TypeError("Harness path cannot contain dot segments"); + } + return encodeURIComponent(part); +} + +export function harnessTargetUrl( + path: string[], + namespace: "v1" | "api" = "v1", + target: HarnessTarget = "managed", +): string { + const targetPath = path.map(harnessPathSegment).join("/"); + return `${harnessBaseUrl(target)}/${namespace}/${targetPath}`; +} + +async function proxyToHarnessNamespace( + request: Request, + path: string[], + namespace: "v1" | "api", + target: HarnessTarget, + bodyLimitBytes = 256 * 1024, +): Promise { + const sourceUrl = new URL(request.url); + let upstreamTarget: string; + try { + upstreamTarget = `${harnessTargetUrl(path, namespace, target)}${sourceUrl.search}`; + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Harness path is invalid" }, + { status: 400 }, + ); + } + const headers = upstreamRequestHeaders(request.headers); + + 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(upstreamTarget, { + 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(target)}: ${ + error instanceof Error ? error.message : "fetch failed" + }`, + }, + { status: 502 }, + ); + } + + return new Response(upstream.body, { + status: upstream.status, + headers: downstreamResponseHeaders(upstream.headers), + }); +} + +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/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-model.test.ts b/frontend/src/features/harness/harness-page-model.test.ts new file mode 100644 index 000000000..47a66d62e --- /dev/null +++ b/frontend/src/features/harness/harness-page-model.test.ts @@ -0,0 +1,325 @@ +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", + "complete", + "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..85514af2a --- /dev/null +++ b/frontend/src/features/harness/harness-page-model.ts @@ -0,0 +1,290 @@ +// 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([ + "complete", + "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 new file mode 100644 index 000000000..95b5adc4a --- /dev/null +++ b/frontend/src/features/harness/harness-page.tsx @@ -0,0 +1,1196 @@ +"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 { + 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 = { + 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 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; + caution?: string; +}; +type ManagedModesResponse = { + kind?: string; + default?: string; + default_execution_profile?: string; + modes?: ManagedMode[]; + execution_profiles?: ManagedProfile[]; +}; +type ManagedSetupResponse = { + allowed_api_key_envs?: string[]; + suggested_check?: string; + verification_command?: string; + verification_contract?: { shell?: boolean; summary?: 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; + 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" || 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"; +} + +function formatContext(tokens: number): string { + if (tokens >= 1000) return `${Math.round(tokens / 1000)}k context`; + 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() { + 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); + 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"); + setRoutes(payload.routes ?? []); + setSelectionPolicy(payload.selection?.policy ?? "harness_decides"); + }, []); + + 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, { 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 { + 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(() => { + 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") => { + if (!managedTask?.id) { + setGoalError("The current goal changed. Refresh before trying that action again."); + return; + } + setGoalBusy(true); + setGoalError(""); + try { + const payload = await readJson( + `${goalApiPrefix}/tasks/current/${action}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + task_id: managedTask.id, + ...(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 + : [ + { + key: goalMode || "quick", + label: "Standard goal loop", + best_for: "Plan, act, verify, and keep working until the goal reaches a terminal result.", + }, + ]; + 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 showStartBlocker = Boolean(startBlocker && !goalError && !error); + const goalOutcome = describeGoalOutcome(managedTask); + + 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 = resolveTaskEnvelope(payload); + setTask(nextTask); + setEvents(nextTask?.events ?? []); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Canary could not start"); + } finally { + setRunning(false); + } + }; + + 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 = resolveTaskEnvelope(payload); + setTask(nextTask); + setEvents(nextTask?.events ?? []); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Model analysis could not start"); + } finally { + setRunning(false); + } + }; + + return ( + + + void refresh()} + > + + + } + /> + + +
+
+ +