diff --git a/apps/loopover-miner-ui/src/attempt-api.test.ts b/apps/loopover-miner-ui/src/attempt-api.test.ts new file mode 100644 index 0000000000..9145cb985a --- /dev/null +++ b/apps/loopover-miner-ui/src/attempt-api.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, vi } from "vitest"; + +import { requestAttempt } from "./lib/attempt"; +import { type AttemptApiDeps, attemptApiPlugin, handleAttemptRequest, matchAttemptRoute } from "../vite-attempt-api"; + +const SUBMITTED_RESULT = { outcome: "attempt_submitted", repoFullName: "acme/widgets", issueNumber: 7 }; +const BLOCKED_RESULT = { outcome: "blocked_rejection_signaled", repoFullName: "acme/widgets", issueNumber: 7 }; +const VALID_BODY = { repoFullName: "acme/widgets", issueNumber: 7, minerLogin: "alice" }; + +/** A fake `runAttempt` that reports a structured success via onResult and exits 0 (the clean-submit path). */ +function successDeps(): { deps: AttemptApiDeps; runAttempt: ReturnType } { + const runAttempt = vi.fn(async (_args: string[], options: { onResult: (r: unknown) => void }) => { + options.onResult(SUBMITTED_RESULT); + return 0; + }); + return { deps: { runAttempt }, runAttempt }; +} + +function fakeReq(method: string, url: string, body: string) { + let dataCb: ((chunk: Buffer) => void) | undefined; + return { + method, + url, + on(event: string, cb: (arg?: unknown) => void) { + if (event === "data") dataCb = cb as (chunk: Buffer) => void; + else if (event === "end") { + if (body) dataCb?.(Buffer.from(body)); + cb(); + } + return this; + }, + } as unknown as { method?: string; url?: string } & NodeJS.ReadableStream; +} + +describe("matchAttemptRoute (#6522)", () => { + it("matches only POST /api/attempt, and no sibling method/path", () => { + expect(matchAttemptRoute("POST", "/api/attempt")).toBe("attempt-post"); + expect(matchAttemptRoute("GET", "/api/attempt")).toBeNull(); + expect(matchAttemptRoute("POST", "/api/discover")).toBeNull(); // sibling route path + expect(matchAttemptRoute("POST", "/api/governor/pause")).toBeNull(); + expect(matchAttemptRoute(undefined, undefined)).toBeNull(); + }); +}); + +describe("handleAttemptRequest (#6522)", () => { + it("passes a well-formed body to runAttempt and returns the captured result + exit code", async () => { + const { deps, runAttempt } = successDeps(); + const handled = await handleAttemptRequest( + "POST", + "/api/attempt", + JSON.stringify({ ...VALID_BODY, base: "develop", live: true, dryRun: true, json: true }), + deps, + ); + expect(runAttempt).toHaveBeenCalledWith( + ["acme/widgets", "7", "--miner-login", "alice", "--base", "develop", "--live", "--dry-run", "--json"], + expect.objectContaining({ onResult: expect.any(Function) }), + ); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ result: SUBMITTED_RESULT, exitCode: 0 }) }); + }); + + it("returns the structured result together with a NON-zero exit for a governed rejection", async () => { + const runAttempt = vi.fn(async (_args: string[], options: { onResult: (r: unknown) => void }) => { + options.onResult(BLOCKED_RESULT); + return 5; // governed rejection still returns a structured result AND a non-zero exit + }); + const handled = await handleAttemptRequest("POST", "/api/attempt", JSON.stringify(VALID_BODY), { runAttempt }); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ result: BLOCKED_RESULT, exitCode: 5 }) }); + }); + + it("returns 400 for a malformed or requirement-missing body, without ever calling runAttempt", async () => { + const runAttempt = vi.fn(); + const invalid = { status: 400, body: JSON.stringify({ error: "invalid_request_body" }) }; + expect(await handleAttemptRequest("POST", "/api/attempt", "", { runAttempt })).toEqual(invalid); + expect(await handleAttemptRequest("POST", "/api/attempt", "not json", { runAttempt })).toEqual(invalid); + expect(await handleAttemptRequest("POST", "/api/attempt", JSON.stringify(["x"]), { runAttempt })).toEqual(invalid); + expect( + await handleAttemptRequest( + "POST", + "/api/attempt", + JSON.stringify({ repoFullName: "acme/widgets", issueNumber: 7 }), + { + runAttempt, + }, + ), + ).toEqual(invalid); // missing minerLogin + expect( + await handleAttemptRequest("POST", "/api/attempt", JSON.stringify({ ...VALID_BODY, issueNumber: 0 }), { + runAttempt, + }), + ).toEqual(invalid); // issueNumber must be a positive integer + expect( + await handleAttemptRequest("POST", "/api/attempt", JSON.stringify({ ...VALID_BODY, issueNumber: 1.5 }), { + runAttempt, + }), + ).toEqual(invalid); + expect(runAttempt).not.toHaveBeenCalled(); + }); + + it("returns a structured 502 when runAttempt exits non-zero WITHOUT a structured result", async () => { + const runAttempt = vi.fn(async () => 1); // parse-error/paused/unexpected branch: never calls onResult + const handled = await handleAttemptRequest("POST", "/api/attempt", JSON.stringify(VALID_BODY), { runAttempt }); + expect(handled).toEqual({ status: 502, body: JSON.stringify({ error: "attempt_failed", exitCode: 1 }) }); + }); + + it("imposes no route-level timeout on a slow attempt (a minutes-long worktree run)", async () => { + let resolveRun: ((code: number) => void) | undefined; + let captured: ((r: unknown) => void) | undefined; + const runAttempt = vi.fn((_args: string[], options: { onResult: (r: unknown) => void }) => { + captured = options.onResult; + return new Promise((resolve) => { + resolveRun = resolve; + }); + }); + const pending = handleAttemptRequest("POST", "/api/attempt", JSON.stringify(VALID_BODY), { runAttempt }); + // The handler must still be waiting on the injected fake — no timeout fired it early. + const settledEarly = await Promise.race([pending, Promise.resolve("still-pending")]); + expect(settledEarly).toBe("still-pending"); + captured?.(SUBMITTED_RESULT); + resolveRun?.(0); + expect(await pending).toEqual({ status: 200, body: JSON.stringify({ result: SUBMITTED_RESULT, exitCode: 0 }) }); + }); + + it("returns 500 (message + safe fallback) when runAttempt throws", async () => { + const throwsError = vi.fn(async () => { + throw new Error("worktree exploded"); + }); + expect( + await handleAttemptRequest("POST", "/api/attempt", JSON.stringify(VALID_BODY), { runAttempt: throwsError }), + ).toEqual({ status: 500, body: JSON.stringify({ error: "worktree exploded" }) }); + const throwsNonError = vi.fn(async () => { + throw "nope"; + }); + expect( + await handleAttemptRequest("POST", "/api/attempt", JSON.stringify(VALID_BODY), { runAttempt: throwsNonError }), + ).toEqual({ status: 500, body: JSON.stringify({ error: "failed to run local attempt" }) }); + }); + + it("falls through (null) for non-attempt method/path combinations", async () => { + const { deps } = successDeps(); + expect(await handleAttemptRequest("GET", "/api/attempt", "", deps)).toBeNull(); + expect(await handleAttemptRequest("POST", "/api/other", "", deps)).toBeNull(); + }); + + it("never threads a credential-shaped body field into runAttempt", async () => { + const { deps, runAttempt } = successDeps(); + await handleAttemptRequest( + "POST", + "/api/attempt", + JSON.stringify({ ...VALID_BODY, githubToken: "ghp_secret", token: "t", apiKey: "k" }), + deps, + ); + const [args] = runAttempt.mock.calls[0]; + expect(args).toEqual(["acme/widgets", "7", "--miner-login", "alice"]); + expect(JSON.stringify(runAttempt.mock.calls[0])).not.toContain("ghp_secret"); + }); +}); + +describe("attemptApiPlugin middleware (#6522)", () => { + it("serves a matching request and passes every other request through to next()", async () => { + const { deps } = successDeps(); + const plugin = attemptApiPlugin(deps); + let middleware: ((req: unknown, res: unknown, next: () => void) => void) | undefined; + const server = { middlewares: { use: (fn: typeof middleware) => (middleware = fn) } }; + (plugin.configureServer as (s: unknown) => void)(server); + (plugin.configurePreviewServer as (s: unknown) => void)(server); + expect(middleware).toBeTypeOf("function"); + + const next = vi.fn(); + middleware!(fakeReq("GET", "/api/other", ""), {}, next); + expect(next).toHaveBeenCalledTimes(1); + + const res = { statusCode: 0, setHeader: vi.fn(), end: vi.fn() }; + await new Promise((resolve) => { + res.end = vi.fn(() => resolve()); + middleware!(fakeReq("POST", "/api/attempt", JSON.stringify(VALID_BODY)), res, vi.fn()); + }); + expect(res.statusCode).toBe(200); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/json"); + }); +}); + +describe("requestAttempt client (#6522)", () => { + it("POSTs the input to /api/attempt and returns the parsed result on success", async () => { + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ result: SUBMITTED_RESULT, exitCode: 0 }), { status: 200 }), + ); + const result = await requestAttempt(VALID_BODY, fetchImpl as unknown as typeof fetch); + expect(result).toEqual({ ok: true, result: SUBMITTED_RESULT, exitCode: 0 }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/attempt"); + expect(init.method).toBe("POST"); + }); + + it("returns a typed error on a non-2xx response", async () => { + const fetchImpl = vi.fn(async () => new Response("{}", { status: 502 })); + expect(await requestAttempt(VALID_BODY, fetchImpl as unknown as typeof fetch)).toEqual({ + ok: false, + error: "local attempt API responded 502", + }); + }); + + it("returns a typed error on an unexpected payload shape", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ nope: 1 }), { status: 200 })); + expect(await requestAttempt(VALID_BODY, fetchImpl as unknown as typeof fetch)).toEqual({ + ok: false, + error: "local attempt API returned an unexpected payload shape", + }); + }); + + it("returns a typed error when fetch rejects (Error and non-Error)", async () => { + const rejects = vi.fn(async () => { + throw new Error("offline"); + }); + expect(await requestAttempt(VALID_BODY, rejects as unknown as typeof fetch)).toEqual({ + ok: false, + error: "offline", + }); + const rejectsNonError = vi.fn(async () => { + throw "x"; + }); + expect(await requestAttempt(VALID_BODY, rejectsNonError as unknown as typeof fetch)).toEqual({ + ok: false, + error: "failed to reach the local attempt API", + }); + }); +}); diff --git a/apps/loopover-miner-ui/src/discover-api.test.ts b/apps/loopover-miner-ui/src/discover-api.test.ts new file mode 100644 index 0000000000..dd4495d3e3 --- /dev/null +++ b/apps/loopover-miner-ui/src/discover-api.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from "vitest"; + +import { requestDiscover } from "./lib/discover"; +import { + type DiscoverApiDeps, + discoverApiPlugin, + handleDiscoverRequest, + matchDiscoverRoute, +} from "../vite-discover-api"; + +const SAMPLE_RESULT = { fanOutCount: 2, ranked: [], enqueueSummary: { enqueued: 2 } }; + +/** A fake `runDiscover` that reports a structured success via onResult and exits 0 (the happy path). */ +function successDeps(): { deps: DiscoverApiDeps; runDiscover: ReturnType } { + const runDiscover = vi.fn(async (_args: string[], options: { onResult: (r: unknown) => void }) => { + options.onResult(SAMPLE_RESULT); + return 0; + }); + return { deps: { runDiscover }, runDiscover }; +} + +/** Minimal readable-request stub: replays the body to the `data` listener when `end` is subscribed, matching how + * the plugin's readRequestBody consumes the stream. */ +function fakeReq(method: string, url: string, body: string) { + let dataCb: ((chunk: Buffer) => void) | undefined; + return { + method, + url, + on(event: string, cb: (arg?: unknown) => void) { + if (event === "data") dataCb = cb as (chunk: Buffer) => void; + else if (event === "end") { + if (body) dataCb?.(Buffer.from(body)); + cb(); + } + return this; + }, + } as unknown as { method?: string; url?: string } & NodeJS.ReadableStream; +} + +describe("matchDiscoverRoute (#6522)", () => { + it("matches only POST /api/discover, and no sibling method/path", () => { + expect(matchDiscoverRoute("POST", "/api/discover")).toBe("discover-post"); + expect(matchDiscoverRoute("GET", "/api/discover")).toBeNull(); + expect(matchDiscoverRoute("POST", "/api/attempt")).toBeNull(); // sibling route path + expect(matchDiscoverRoute("POST", "/api/governor/pause")).toBeNull(); + expect(matchDiscoverRoute(undefined, undefined)).toBeNull(); + }); +}); + +describe("handleDiscoverRequest (#6522)", () => { + it("passes a well-formed body to runDiscover and returns the captured structured result", async () => { + const { deps, runDiscover } = successDeps(); + const handled = await handleDiscoverRequest( + "POST", + "/api/discover", + JSON.stringify({ targets: ["acme/widgets"], dryRun: true, json: true }), + deps, + ); + expect(runDiscover).toHaveBeenCalledWith( + ["acme/widgets", "--dry-run", "--json"], + expect.objectContaining({ onResult: expect.any(Function) }), + ); + expect(handled).toEqual({ status: 200, body: JSON.stringify({ result: SAMPLE_RESULT, exitCode: 0 }) }); + }); + + it("builds --search / --api-base-url / --token-env args from the body", async () => { + const { deps, runDiscover } = successDeps(); + await handleDiscoverRequest( + "POST", + "/api/discover", + JSON.stringify({ search: "label:bug", apiBaseUrl: "https://forge.example", tokenEnv: "FORGE_PAT" }), + deps, + ); + expect(runDiscover).toHaveBeenCalledWith( + ["--search", "label:bug", "--api-base-url", "https://forge.example", "--token-env", "FORGE_PAT"], + expect.anything(), + ); + }); + + it("returns 400 for a malformed or requirement-missing body, without ever calling runDiscover", async () => { + const runDiscover = vi.fn(); + const invalid = { status: 400, body: JSON.stringify({ error: "invalid_request_body" }) }; + expect( + await handleDiscoverRequest("POST", "/api/discover", JSON.stringify({ dryRun: true }), { runDiscover }), + ).toEqual(invalid); + expect(await handleDiscoverRequest("POST", "/api/discover", "not json", { runDiscover })).toEqual(invalid); + expect(await handleDiscoverRequest("POST", "/api/discover", "", { runDiscover })).toEqual(invalid); + expect(await handleDiscoverRequest("POST", "/api/discover", JSON.stringify(["a"]), { runDiscover })).toEqual( + invalid, + ); + expect( + await handleDiscoverRequest("POST", "/api/discover", JSON.stringify({ targets: [""] }), { runDiscover }), + ).toEqual(invalid); + expect(runDiscover).not.toHaveBeenCalled(); + }); + + it("returns a structured 502 when runDiscover exits non-zero without a structured result", async () => { + const runDiscover = vi.fn(async () => 2); // never calls onResult + const handled = await handleDiscoverRequest( + "POST", + "/api/discover", + JSON.stringify({ targets: ["acme/widgets"] }), + { runDiscover }, + ); + expect(handled).toEqual({ status: 502, body: JSON.stringify({ error: "discover_failed", exitCode: 2 }) }); + }); + + it("returns 500 with the error message when runDiscover throws", async () => { + const runDiscover = vi.fn(async () => { + throw new Error("sqlite locked"); + }); + expect( + await handleDiscoverRequest("POST", "/api/discover", JSON.stringify({ targets: ["acme/widgets"] }), { + runDiscover, + }), + ).toEqual({ status: 500, body: JSON.stringify({ error: "sqlite locked" }) }); + }); + + it("returns 500 with a safe message when runDiscover throws a non-Error", async () => { + const runDiscover = vi.fn(async () => { + throw "nope"; + }); + expect( + await handleDiscoverRequest("POST", "/api/discover", JSON.stringify({ targets: ["acme/widgets"] }), { + runDiscover, + }), + ).toEqual({ status: 500, body: JSON.stringify({ error: "failed to run local discover" }) }); + }); + + it("falls through (null) for non-discover method/path combinations", async () => { + const { deps } = successDeps(); + expect(await handleDiscoverRequest("GET", "/api/discover", "", deps)).toBeNull(); + expect(await handleDiscoverRequest("POST", "/api/other", "", deps)).toBeNull(); + }); + + it("never threads a credential-shaped body field into runDiscover", async () => { + const { deps, runDiscover } = successDeps(); + await handleDiscoverRequest( + "POST", + "/api/discover", + JSON.stringify({ targets: ["acme/widgets"], githubToken: "ghp_secret", token: "t", apiKey: "k" }), + deps, + ); + const [args] = runDiscover.mock.calls[0]; + expect(args).toEqual(["acme/widgets"]); // no credential flag threaded through + expect(JSON.stringify(runDiscover.mock.calls[0])).not.toContain("ghp_secret"); + }); +}); + +describe("discoverApiPlugin middleware (#6522)", () => { + it("serves a matching request and passes every other request through to next()", async () => { + const { deps } = successDeps(); + const plugin = discoverApiPlugin(deps); + let middleware: ((req: unknown, res: unknown, next: () => void) => void) | undefined; + const server = { middlewares: { use: (fn: typeof middleware) => (middleware = fn) } }; + (plugin.configureServer as (s: unknown) => void)(server); + (plugin.configurePreviewServer as (s: unknown) => void)(server); // same attach path, exercised for coverage + expect(middleware).toBeTypeOf("function"); + + const next = vi.fn(); + middleware!(fakeReq("GET", "/api/other", ""), {}, next); + expect(next).toHaveBeenCalledTimes(1); + + const res = { statusCode: 0, setHeader: vi.fn(), end: vi.fn() }; + await new Promise((resolve) => { + res.end = vi.fn(() => resolve()); + middleware!(fakeReq("POST", "/api/discover", JSON.stringify({ targets: ["acme/widgets"] })), res, vi.fn()); + }); + expect(res.statusCode).toBe(200); + expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "application/json"); + }); +}); + +describe("requestDiscover client (#6522)", () => { + it("POSTs the input to /api/discover and returns the parsed result on success", async () => { + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ result: SAMPLE_RESULT, exitCode: 0 }), { status: 200 }), + ); + const result = await requestDiscover({ targets: ["acme/widgets"] }, fetchImpl as unknown as typeof fetch); + expect(result).toEqual({ ok: true, result: SAMPLE_RESULT, exitCode: 0 }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/discover"); + expect(init.method).toBe("POST"); + expect(init.body).toBe(JSON.stringify({ targets: ["acme/widgets"] })); + }); + + it("returns a typed error on a non-2xx response", async () => { + const fetchImpl = vi.fn(async () => new Response("{}", { status: 502 })); + expect(await requestDiscover({ search: "x" }, fetchImpl as unknown as typeof fetch)).toEqual({ + ok: false, + error: "local discover API responded 502", + }); + }); + + it("returns a typed error on an unexpected payload shape", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ nope: 1 }), { status: 200 })); + expect(await requestDiscover({ search: "x" }, fetchImpl as unknown as typeof fetch)).toEqual({ + ok: false, + error: "local discover API returned an unexpected payload shape", + }); + }); + + it("returns a typed error when fetch rejects", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("offline"); + }); + expect(await requestDiscover({ search: "x" }, fetchImpl as unknown as typeof fetch)).toEqual({ + ok: false, + error: "offline", + }); + }); + + it("returns a safe error message when fetch rejects with a non-Error", async () => { + const fetchImpl = vi.fn(async () => { + throw "x"; + }); + expect(await requestDiscover({ search: "x" }, fetchImpl as unknown as typeof fetch)).toEqual({ + ok: false, + error: "failed to reach the local discover API", + }); + }); +}); diff --git a/apps/loopover-miner-ui/src/lib/attempt.ts b/apps/loopover-miner-ui/src/lib/attempt.ts new file mode 100644 index 0000000000..887cb56e9d --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/attempt.ts @@ -0,0 +1,60 @@ +// Client for the local attempt action API (#6522), the miner-ui's HTTP surface over the `attempt` CLI command. +// Mirrors governor.ts / discover.ts: a typed discriminated result, never a thrown exception for an HTTP-level +// failure, and a guard narrowing the parsed payload. Safe only because vite-auth.ts authenticates every /api/* +// request. `/api/attempt` can run for minutes (a real worktree + coding-agent iteration), so a caller that needs +// a deadline applies it here at the fetch layer — the route imposes none. + +export const ATTEMPT_API_PATH = "/api/attempt"; + +/** Non-secret attempt inputs — never a credential; `runAttempt` resolves its own token server-side. */ +export type AttemptActionInput = { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base?: string; + live?: boolean; + dryRun?: boolean; + json?: boolean; +}; + +/** `result` is the structured AttemptCliResult the CLI emits (with its own `outcome`); `exitCode` is returned + * alongside so a caller can tell a governed rejection/blocked outcome from a clean success without re-deriving + * it from the result shape. Kept as `unknown` so a later chat/message-list issue types it against the shared + * contract without this fetcher coupling to the full union. */ +export type AttemptActionResult = { ok: true; result: unknown; exitCode: number } | { ok: false; error: string }; + +function isAttemptSuccessPayload(value: unknown): value is { result: unknown; exitCode: number } { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return "result" in record && typeof record.exitCode === "number"; +} + +async function parseAttemptResponse(response: Response, apiLabel: string): Promise { + if (!response.ok) return { ok: false, error: `${apiLabel} responded ${response.status}` }; + const payload: unknown = await response.json(); + if (!isAttemptSuccessPayload(payload)) { + return { ok: false, error: `${apiLabel} returned an unexpected payload shape` }; + } + return { ok: true, result: payload.result, exitCode: payload.exitCode }; +} + +/** Run a local attempt (mirrors `loopover-miner attempt --miner-login [--live] + * [--dry-run]`); an HTTP or shape failure surfaces as a typed error result the view renders, never a crash. */ +export async function requestAttempt( + input: AttemptActionInput, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const response = await fetchImpl(ATTEMPT_API_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return await parseAttemptResponse(response, "local attempt API"); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local attempt API", + }; + } +} diff --git a/apps/loopover-miner-ui/src/lib/discover.ts b/apps/loopover-miner-ui/src/lib/discover.ts new file mode 100644 index 0000000000..51cd8a4bb8 --- /dev/null +++ b/apps/loopover-miner-ui/src/lib/discover.ts @@ -0,0 +1,55 @@ +// Client for the local discover action API (#6522), the miner-ui's HTTP surface over the `discover` CLI command. +// Mirrors governor.ts's shape: a typed discriminated result, never a thrown exception for an HTTP-level failure, +// and a guard narrowing the parsed payload. Safe only because vite-auth.ts authenticates every /api/* request. + +export const DISCOVER_API_PATH = "/api/discover"; + +/** Non-secret discover inputs — never a credential; the server resolves its own token, exactly as the CLI does. */ +export type DiscoverActionInput = { + targets?: string[]; + search?: string; + dryRun?: boolean; + json?: boolean; + apiBaseUrl?: string; + tokenEnv?: string; +}; + +/** `result` is the structured DiscoverResult the CLI emits; kept as `unknown` here so a later chat/message-list + * issue can type it against the shared contract without this fetcher coupling to the full result shape. */ +export type DiscoverActionResult = { ok: true; result: unknown; exitCode: number } | { ok: false; error: string }; + +function isDiscoverSuccessPayload(value: unknown): value is { result: unknown; exitCode: number } { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return "result" in record && typeof record.exitCode === "number"; +} + +async function parseDiscoverResponse(response: Response, apiLabel: string): Promise { + if (!response.ok) return { ok: false, error: `${apiLabel} responded ${response.status}` }; + const payload: unknown = await response.json(); + if (!isDiscoverSuccessPayload(payload)) { + return { ok: false, error: `${apiLabel} returned an unexpected payload shape` }; + } + return { ok: true, result: payload.result, exitCode: payload.exitCode }; +} + +/** Run a local discover (mirrors `loopover-miner discover [--dry-run] [--json]`); an HTTP + * or shape failure surfaces as a typed error result the view renders, never a crash. */ +export async function requestDiscover( + input: DiscoverActionInput, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const response = await fetchImpl(DISCOVER_API_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + return await parseDiscoverResponse(response, "local discover API"); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "failed to reach the local discover API", + }; + } +} diff --git a/apps/loopover-miner-ui/vite-attempt-api.ts b/apps/loopover-miner-ui/vite-attempt-api.ts new file mode 100644 index 0000000000..b25cb11dfc --- /dev/null +++ b/apps/loopover-miner-ui/vite-attempt-api.ts @@ -0,0 +1,183 @@ +import type { Plugin } from "vite"; + +// attempt HTTP surface for the miner-ui (#6522): a thin, non-bypassing bridge to the EXISTING `runAttempt` +// entry point (attempt-cli.js), the same one the CLI's `attempt` subcommand calls. It reimplements none of the +// worktree / coding-agent / chokepoint pipeline — its only job is to marshal a POST body into `runAttempt`'s +// CLI-style args array and marshal the structured result (already threaded through `runAttempt`'s own onResult) +// back out. Because it calls the real, unmodified `runAttempt`, it inherits that command's Governor chokepoint +// gate for free (attempt-runner.js routes every write through it), exactly as this route inherits vite-auth.ts's +// cookie gate for free by living under /api/* and being registered after authPlugin() in vite.config.ts. +// +// discover's route is vite-discover-api.ts (the other half of #6522). +// +// Unlike every other /api/* route in this app (synchronous local-store reads/writes), POST /api/attempt can run +// for MINUTES — it drives a full worktree checkout + coding-agent iteration — so this handler imposes no timeout +// of its own; a caller that needs one applies it at the fetch layer. +// +// matchAttemptRoute() is a pure synchronous check, run before any request body is read. + +/** Non-secret attempt inputs accepted from the POST body — the exact CLI flags `parseAttemptArgs` accepts, + * never a credential. Credential resolution happens server-side in `runAttempt` (GITHUB_TOKEN / live session), + * so a `githubToken`/`token`/`apiKey`-shaped field on the body is intentionally never read. */ +type AttemptRequest = { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base?: string; + live: boolean; + dryRun: boolean; + json: boolean; +}; + +export type AttemptApiDeps = { + /** The real `runAttempt` from attempt-cli.js — injectable so tests never touch a real worktree, coding agent, + * or ledger. `onResult` captures the structured AttemptCliResult (which `runAttempt` already emits at every + * real return point) so the route can return it alongside the raw exit code. */ + runAttempt: (args: string[], options: { onResult: (result: unknown) => void }) => Promise; +}; + +const defaultDeps: AttemptApiDeps = { + runAttempt: async (args, options) => { + const mod = (await import("../../packages/loopover-miner/lib/attempt-cli.js")) as { + runAttempt: (args: string[], options?: { onResult?: (result: unknown) => void }) => Promise; + }; + return mod.runAttempt(args, options); + }, +}; + +export type AttemptRoute = "attempt-post"; + +/** Pure route matcher — safe to call synchronously before reading a request body. */ +export function matchAttemptRoute(method: string | undefined, url: string | undefined): AttemptRoute | null { + if (url === "/api/attempt" && method === "POST") return "attempt-post"; + return null; +} + +function readRequestBody(req: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += chunk.toString(); + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +/** Parse the POST body into the non-secret attempt inputs, or null when it is malformed or missing a required + * field (`repoFullName`, a positive-integer `issueNumber`, and `minerLogin` are all required — the same three + * `parseAttemptArgs` requires). Credential-shaped fields are never read off the body. */ +function parseAttemptBody(rawBody: string): AttemptRequest | null { + if (!rawBody.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + + const repoFullName = typeof record.repoFullName === "string" ? record.repoFullName.trim() : ""; + const minerLogin = typeof record.minerLogin === "string" ? record.minerLogin.trim() : ""; + const issueNumber = typeof record.issueNumber === "number" ? record.issueNumber : Number.NaN; + if (!repoFullName || !minerLogin) return null; + if (!Number.isInteger(issueNumber) || issueNumber < 1) return null; + + const request: AttemptRequest = { + repoFullName, + issueNumber, + minerLogin, + live: record.live === true, + dryRun: record.dryRun === true, + json: record.json === true, + }; + if (typeof record.base === "string" && record.base.trim()) request.base = record.base.trim(); + return request; +} + +/** Build `runAttempt`'s CLI-style args from the parsed body — its only user-facing entry point is + * `parseAttemptArgs(args: string[])`, so the route constructs argv rather than calling a lower-level path. */ +function buildAttemptArgs(request: AttemptRequest): string[] { + const args: string[] = [request.repoFullName, String(request.issueNumber), "--miner-login", request.minerLogin]; + if (request.base !== undefined) args.push("--base", request.base); + if (request.live) args.push("--live"); + if (request.dryRun) args.push("--dry-run"); + if (request.json) args.push("--json"); + return args; +} + +async function respondToAttemptRoute(rawBody: string, deps: AttemptApiDeps): Promise<{ status: number; body: string }> { + const request = parseAttemptBody(rawBody); + if (!request) { + return { status: 400, body: JSON.stringify({ error: "invalid_request_body" }) }; + } + try { + let result: unknown; + let captured = false; + const exitCode = await deps.runAttempt(buildAttemptArgs(request), { + onResult: (value) => { + result = value; + captured = true; + }, + }); + // runAttempt fires onResult at every real structured outcome (dry-run, rejected, blocked, infeasible, final) + // — including governed rejections that still return a non-zero exit — so the result plus the raw exit code is + // returned together. The parse-error/paused/unexpected-error branches never call onResult and have no + // structured result to return; surface those as an error instead of assuming a result is present. + if (!captured) { + return { status: 502, body: JSON.stringify({ error: "attempt_failed", exitCode }) }; + } + return { status: 200, body: JSON.stringify({ result, exitCode }) }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to run local attempt"; + return { status: 500, body: JSON.stringify({ error: message }) }; + } +} + +/** Request handler factored out for direct unit tests (mirrors vite-governor-api.ts). Returns null when the + * request is not the attempt route. */ +export async function handleAttemptRequest( + method: string | undefined, + url: string | undefined, + rawBody: string, + deps: AttemptApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + const route = matchAttemptRoute(method, url); + if (!route) return null; + return respondToAttemptRoute(rawBody, deps); +} + +/** Vite dev/preview middleware serving POST /api/attempt. */ +export function attemptApiPlugin(deps: AttemptApiDeps = defaultDeps): Plugin { + const attach = (middlewares: { + use: ( + fn: ( + req: { method?: string; url?: string } & NodeJS.ReadableStream, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + const route = matchAttemptRoute(req.method, req.url); + if (!route) return next(); + void readRequestBody(req) + .then((rawBody) => respondToAttemptRoute(rawBody, deps)) + .then((handled) => { + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + }); + }); + }; + return { + name: "gittensory-miner-ui:attempt-api", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} diff --git a/apps/loopover-miner-ui/vite-discover-api.ts b/apps/loopover-miner-ui/vite-discover-api.ts new file mode 100644 index 0000000000..176aa476c8 --- /dev/null +++ b/apps/loopover-miner-ui/vite-discover-api.ts @@ -0,0 +1,185 @@ +import type { Plugin } from "vite"; + +// discover HTTP surface for the miner-ui (#6522): the first HTTP route for the AMS miner's own action-taking +// commands. `discover` existed only as a CLI subcommand (packages/loopover-miner/bin/loopover-miner.js) until +// now — this is a thin, non-bypassing bridge to the EXISTING `runDiscover` entry point (discover-cli.js), the +// same one the CLI calls. It reimplements none of the fan-out/rank/enqueue pipeline; its only job is to marshal +// a POST body into `runDiscover`'s CLI-style args array and marshal the structured result back out. +// +// Attempt's release/requeue-equivalent pair lives in vite-attempt-api.ts (the other half of #6522). +// +// Like every sibling /api/* route, this middleware is registered AFTER authPlugin() in vite.config.ts, so an +// unauthenticated request is rejected before it ever reaches this handler — there is no per-route auth wiring. +// `discover` has no Governor chokepoint of its own (it only fans out + ranks + enqueues, none of the gated +// write actions), so — matching the CLI exactly — this route adds none either. +// +// matchDiscoverRoute() is a pure synchronous check, run before any request body is read, so every unrelated +// request (assets, other /api/* routes) falls straight through to next() without this plugin touching its stream. + +/** Non-secret discover inputs accepted from the POST body — the exact CLI flags `parseDiscoverArgs` accepts, + * never a credential. `githubToken`/`token`/`apiKey`-shaped fields are intentionally never read (the miner's + * local harness resolves its own credentials server-side, exactly as the CLI does). */ +type DiscoverRequest = { + targets: string[]; + search: string | null; + dryRun: boolean; + json: boolean; + apiBaseUrl?: string; + tokenEnv?: string; +}; + +export type DiscoverApiDeps = { + /** The real `runDiscover` from discover-cli.js — injectable so tests never touch a real store, network, or + * worktree. `onResult` captures the structured outcome (#6522) without depending on the exit code alone. */ + runDiscover: (args: string[], options: { onResult: (result: unknown) => void }) => Promise; +}; + +const defaultDeps: DiscoverApiDeps = { + runDiscover: async (args, options) => { + const mod = (await import("../../packages/loopover-miner/lib/discover-cli.js")) as { + runDiscover: (args: string[], options?: { onResult?: (result: unknown) => void }) => Promise; + }; + return mod.runDiscover(args, options); + }, +}; + +export type DiscoverRoute = "discover-post"; + +/** Pure route matcher — safe to call synchronously before reading a request body. */ +export function matchDiscoverRoute(method: string | undefined, url: string | undefined): DiscoverRoute | null { + if (url === "/api/discover" && method === "POST") return "discover-post"; + return null; +} + +function readRequestBody(req: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk: Buffer | string) => { + body += chunk.toString(); + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +function isNonEmptyStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string" && entry.trim()); +} + +/** Parse the POST body into the non-secret discover inputs, or null when it is malformed or has neither a + * repository target nor a search query (parseDiscoverArgs requires exactly one of the two). Credential-shaped + * fields are never read off the body. */ +function parseDiscoverBody(rawBody: string): DiscoverRequest | null { + if (!rawBody.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const record = parsed as Record; + + const targets = isNonEmptyStringArray(record.targets) ? record.targets.map((entry) => entry.trim()) : []; + const search = typeof record.search === "string" && record.search.trim() ? record.search.trim() : null; + if (targets.length === 0 && search === null) return null; + + const request: DiscoverRequest = { + targets, + search, + dryRun: record.dryRun === true, + json: record.json === true, + }; + if (typeof record.apiBaseUrl === "string" && record.apiBaseUrl.trim()) request.apiBaseUrl = record.apiBaseUrl.trim(); + if (typeof record.tokenEnv === "string" && record.tokenEnv.trim()) request.tokenEnv = record.tokenEnv.trim(); + return request; +} + +/** Build `runDiscover`'s CLI-style args from the parsed body — its only user-facing entry point is + * `parseDiscoverArgs(args: string[])`, so there is no lower-level structured-input path to call instead. */ +function buildDiscoverArgs(request: DiscoverRequest): string[] { + const args: string[] = [...request.targets]; + if (request.search !== null) args.push("--search", request.search); + if (request.dryRun) args.push("--dry-run"); + if (request.json) args.push("--json"); + if (request.apiBaseUrl !== undefined) args.push("--api-base-url", request.apiBaseUrl); + if (request.tokenEnv !== undefined) args.push("--token-env", request.tokenEnv); + return args; +} + +async function respondToDiscoverRoute( + rawBody: string, + deps: DiscoverApiDeps, +): Promise<{ status: number; body: string }> { + const request = parseDiscoverBody(rawBody); + if (!request) { + return { status: 400, body: JSON.stringify({ error: "invalid_request_body" }) }; + } + try { + let result: unknown; + let captured = false; + const exitCode = await deps.runDiscover(buildDiscoverArgs(request), { + onResult: (value) => { + result = value; + captured = true; + }, + }); + // runDiscover fires onResult only at a real structured success point; a non-zero exit that never called it + // (a parse-error/unexpected-error branch) has no result object to return, so surface it as an error rather + // than crashing on an assumed-present result. + if (!captured) { + return { status: 502, body: JSON.stringify({ error: "discover_failed", exitCode }) }; + } + return { status: 200, body: JSON.stringify({ result, exitCode }) }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to run local discover"; + return { status: 500, body: JSON.stringify({ error: message }) }; + } +} + +/** Request handler factored out for direct unit tests (mirrors vite-governor-api.ts). Returns null when the + * request is not the discover route. */ +export async function handleDiscoverRequest( + method: string | undefined, + url: string | undefined, + rawBody: string, + deps: DiscoverApiDeps = defaultDeps, +): Promise<{ status: number; body: string } | null> { + const route = matchDiscoverRoute(method, url); + if (!route) return null; + return respondToDiscoverRoute(rawBody, deps); +} + +/** Vite dev/preview middleware serving POST /api/discover. */ +export function discoverApiPlugin(deps: DiscoverApiDeps = defaultDeps): Plugin { + const attach = (middlewares: { + use: ( + fn: ( + req: { method?: string; url?: string } & NodeJS.ReadableStream, + res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, + next: () => void, + ) => void, + ) => void; + }) => { + middlewares.use((req, res, next) => { + const route = matchDiscoverRoute(req.method, req.url); + if (!route) return next(); + void readRequestBody(req) + .then((rawBody) => respondToDiscoverRoute(rawBody, deps)) + .then((handled) => { + res.statusCode = handled.status; + res.setHeader("Content-Type", "application/json"); + res.end(handled.body); + }); + }); + }; + return { + name: "gittensory-miner-ui:discover-api", + configureServer(server) { + attach(server.middlewares); + }, + configurePreviewServer(server) { + attach(server.middlewares); + }, + }; +} diff --git a/apps/loopover-miner-ui/vite.config.ts b/apps/loopover-miner-ui/vite.config.ts index 75d39f5634..754f7a2999 100644 --- a/apps/loopover-miner-ui/vite.config.ts +++ b/apps/loopover-miner-ui/vite.config.ts @@ -4,8 +4,10 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; +import { attemptApiPlugin } from "./vite-attempt-api"; import { authPlugin } from "./vite-auth"; import { chatApiPlugin } from "./vite-chat-api"; +import { discoverApiPlugin } from "./vite-discover-api"; import { governorApiPlugin } from "./vite-governor-api"; import { ledgersApiPlugin } from "./vite-ledgers-api"; import { portfolioQueueActionsApiPlugin } from "./vite-portfolio-queue-actions-api"; @@ -29,6 +31,8 @@ export default defineConfig({ ledgersApiPlugin(), governorApiPlugin(), rankedCandidatesApiPlugin(), + discoverApiPlugin(), + attemptApiPlugin(), ], server: { // Offset from gittensory-ui (5173) so both apps can run side-by-side locally. diff --git a/packages/loopover-miner/lib/discover-cli.d.ts b/packages/loopover-miner/lib/discover-cli.d.ts index 33e612ba05..4d065c56d1 100644 --- a/packages/loopover-miner/lib/discover-cli.d.ts +++ b/packages/loopover-miner/lib/discover-cli.d.ts @@ -85,6 +85,10 @@ export type RunDiscoverOptions = { rankedIssues: RankedCandidateIssue[], options: { queueStore: PortfolioQueueStore }, ) => EnqueueRankedDiscoverySummary; + /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition + * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a + * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ + onResult?: (result: DiscoverResult) => void; }; export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs; diff --git a/packages/loopover-miner/lib/discover-cli.js b/packages/loopover-miner/lib/discover-cli.js index 25fd5c5226..2ddcd6f4fa 100644 --- a/packages/loopover-miner/lib/discover-cli.js +++ b/packages/loopover-miner/lib/discover-cli.js @@ -192,6 +192,10 @@ export async function runDiscover(args, options = {}) { usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, enqueueSummary, }; + // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real + // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- + // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. + options.onResult?.(result); if (parsed.json) { console.log(JSON.stringify(result, null, 2)); } else { @@ -294,6 +298,9 @@ export async function runDiscover(args, options = {}) { enqueueSummary, }; + // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch + // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. + options.onResult?.(result); if (parsed.json) { console.log(JSON.stringify(result, null, 2)); } else { diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 7c223b672b..86c4261984 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -1065,3 +1065,64 @@ describe("loopover-miner discover CLI entrypoint (#4247)", () => { expect(output).toContain("Usage: loopover-miner discover"); }); }); + +describe("runDiscover onResult hook (#6522)", () => { + it("fires options.onResult with the structured result at the full-run success point, alongside exit 0", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1, title: "Add retry helper", labels: ["help wanted", "feature"] })], + warnings: [], + rateLimitRemaining: 4987, + rateLimitResetAt: "2026-07-09T13:00:00.000Z", + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const onResult = vi.fn(); + + const exitCode = await runDiscover(["acme/widgets"], { + nowMs: NOW, + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + fetchCandidateIssuesWithSummary, + onResult, + }); + + expect(exitCode).toBe(0); // additive: the exit code is unchanged by the hook + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith( + expect.objectContaining({ fanOutCount: 1, enqueueSummary: expect.objectContaining({ enqueued: 1 }) }), + ); + }); + + it("fires options.onResult with the dry-run result at the dry-run success point", async () => { + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 2, title: "Fix flaky test" })], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const onResult = vi.fn(); + + const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + onResult, + }); + + expect(exitCode).toBe(0); + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(expect.objectContaining({ outcome: "dry_run", fanOutCount: 1 })); + }); + + it("REGRESSION: onResult never fires on the parse-error reportCliFailure branch, and the non-zero exit is unchanged", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const onResult = vi.fn(); + + const exitCode = await runDiscover(["not-a-repo"], { onResult }); + + expect(exitCode).not.toBe(0); + expect(onResult).not.toHaveBeenCalled(); + }); +});