diff --git a/.env.example b/.env.example index af287ed..e502443 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ # --- App --- # NODE_ENV=development # APP_URL=http://localhost:3000 +# Web → API base URL used by Next.js Route Handlers when proxying to Fastify. +# Defaults to http://localhost:4000 when unset. +# LEARNPRO_API_URL=http://localhost:4000 diff --git a/apps/web/package.json b/apps/web/package.json index dcb3776..76c7fd8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,10 +11,14 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@learnpro/sandbox": "workspace:*", "@learnpro/shared": "workspace:*", + "@monaco-editor/react": "^4.6.0", + "monaco-editor": "^0.52.2", "next": "^15.1.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zod": "^3.24.1" }, "devDependencies": { "@next/eslint-plugin-next": "^15.1.3", diff --git a/apps/web/src/app/api/sandbox/run/route.test.ts b/apps/web/src/app/api/sandbox/run/route.test.ts new file mode 100644 index 0000000..a48582f --- /dev/null +++ b/apps/web/src/app/api/sandbox/run/route.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { POST } from "./route"; + +function postRequest(body: unknown): Request { + return new Request("http://localhost/api/sandbox/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/sandbox/run (Next.js Route Handler)", () => { + const realFetch = globalThis.fetch; + + beforeEach(() => { + process.env["LEARNPRO_API_URL"] = "http://api.test"; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + delete process.env["LEARNPRO_API_URL"]; + }); + + it("forwards a valid request to the API and returns the upstream response", async () => { + const upstreamBody = JSON.stringify({ + stdout: "hi\n", + stderr: "", + exit_code: 0, + duration_ms: 7, + killed_by: null, + language: "python", + runtime_version: "3.10.0", + }); + const fakeFetch = vi.fn().mockResolvedValue( + new Response(upstreamBody, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + globalThis.fetch = fakeFetch as unknown as typeof fetch; + + const res = await POST(postRequest({ language: "python", code: "print('hi')" })); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.stdout).toBe("hi\n"); + expect(fakeFetch).toHaveBeenCalledWith( + "http://api.test/sandbox/run", + expect.objectContaining({ method: "POST" }), + ); + const fwBody = JSON.parse( + (fakeFetch.mock.calls[0]?.[1] as RequestInit).body as string, + ) as Record; + expect(fwBody["language"]).toBe("python"); + expect(fwBody["code"]).toBe("print('hi')"); + }); + + it("returns 400 with error=invalid_json when the body is not JSON", async () => { + const req = new Request("http://localhost/api/sandbox/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + const res = await POST(req); + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe("invalid_json"); + }); + + it("returns 400 with error=invalid_request when the body fails zod validation", async () => { + const res = await POST(postRequest({ language: "rust", code: "" })); + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe("invalid_request"); + }); + + it("returns 502 with error=api_unreachable when the upstream fetch throws", async () => { + globalThis.fetch = vi + .fn() + .mockRejectedValue(new Error("connect ECONNREFUSED")) as unknown as typeof fetch; + + const res = await POST(postRequest({ language: "python", code: "print(1)" })); + expect(res.status).toBe(502); + const json = (await res.json()) as { error: string; message: string }; + expect(json.error).toBe("api_unreachable"); + expect(json.message).toContain("ECONNREFUSED"); + }); + + it("propagates upstream non-2xx status codes through to the client", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "sandbox_unavailable", message: "piston down" }), { + status: 502, + headers: { "content-type": "application/json" }, + }), + ) as unknown as typeof fetch; + + const res = await POST(postRequest({ language: "python", code: "print(1)" })); + expect(res.status).toBe(502); + const json = (await res.json()) as { error: string }; + expect(json.error).toBe("sandbox_unavailable"); + }); + + it("defaults to http://localhost:4000 when LEARNPRO_API_URL is unset", async () => { + delete process.env["LEARNPRO_API_URL"]; + const fakeFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + stdout: "ok", + stderr: "", + exit_code: 0, + duration_ms: 1, + killed_by: null, + language: "python", + runtime_version: "3.10.0", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + globalThis.fetch = fakeFetch as unknown as typeof fetch; + + await POST(postRequest({ language: "python", code: "print(1)" })); + expect(fakeFetch).toHaveBeenCalledWith("http://localhost:4000/sandbox/run", expect.any(Object)); + }); +}); diff --git a/apps/web/src/app/api/sandbox/run/route.ts b/apps/web/src/app/api/sandbox/run/route.ts new file mode 100644 index 0000000..3383da7 --- /dev/null +++ b/apps/web/src/app/api/sandbox/run/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { SandboxRunRequestSchema } from "@learnpro/sandbox"; + +export const runtime = "nodejs"; + +const DEFAULT_API_URL = "http://localhost:4000"; + +export async function POST(req: Request) { + let json: unknown; + try { + json = await req.json(); + } catch { + return NextResponse.json( + { error: "invalid_json", message: "Request body must be valid JSON." }, + { status: 400 }, + ); + } + + const parsed = SandboxRunRequestSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { error: "invalid_request", issues: parsed.error.issues }, + { status: 400 }, + ); + } + + const apiUrl = process.env["LEARNPRO_API_URL"] ?? DEFAULT_API_URL; + let upstream: Response; + try { + upstream = await fetch(`${apiUrl}/sandbox/run`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(parsed.data), + }); + } catch (err) { + return NextResponse.json( + { + error: "api_unreachable", + message: err instanceof Error ? err.message : String(err), + }, + { status: 502 }, + ); + } + + const body = await upstream.text(); + return new NextResponse(body, { + status: upstream.status, + headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }, + }); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 87f4071..83559ad 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -3,9 +3,14 @@ export default function HomePage() {

LearnPro

Adaptive AI-tutored self-hosted learning platform.

-

- Skeleton scaffold (STORY-052). See /health for the smoke check. -

+
); } diff --git a/apps/web/src/app/playground/PlaygroundClient.tsx b/apps/web/src/app/playground/PlaygroundClient.tsx new file mode 100644 index 0000000..c05c2d1 --- /dev/null +++ b/apps/web/src/app/playground/PlaygroundClient.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import type { SandboxLanguage, SandboxRunResponse } from "@learnpro/sandbox"; +import { runSandbox, type RunSandboxResult } from "../../lib/run-sandbox"; +import { statusFor } from "./status"; + +const Editor = dynamic(() => import("@monaco-editor/react").then((m) => m.Editor), { + ssr: false, + loading: () => ( +
+ Loading editor… +
+ ), +}); + +const STARTERS: Record = { + python: "print('hello from python')\n", + typescript: "console.log('hello from typescript')\n", +}; + +const MONACO_LANGUAGE: Record = { + python: "python", + typescript: "typescript", +}; + +export function PlaygroundClient() { + const [language, setLanguage] = useState("python"); + const [code, setCode] = useState(STARTERS.python); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + + const onLanguageChange = useCallback((next: SandboxLanguage) => { + setLanguage(next); + setCode(STARTERS[next]); + setResult(null); + }, []); + + const onRun = useCallback(async () => { + setRunning(true); + setResult(null); + const r = await runSandbox({ language, code }); + setResult(r); + setRunning(false); + }, [language, code]); + + const status = useMemo(() => statusFor(result), [result]); + + return ( +
+
+ + + + + {status.label} + +
+ +
+ setCode(v ?? "")} + options={{ + minimap: { enabled: false }, + fontSize: 14, + scrollBeyondLastLine: false, + tabFocusMode: false, + ariaLabel: "Code editor", + }} + /> +
+ + +
+ ); +} + +function ResultPanel({ result, running }: { result: RunSandboxResult | null; running: boolean }) { + if (running) { + return ( +
+ Running in sandbox… +
+ ); + } + if (result === null) { + return ( +
+ Result will appear here after you press Run. +
+ ); + } + + if (!result.ok) { + return ( +
+ Run failed +
+ {result.error} + {result.message ? ` — ${result.message}` : ""} +
+
+ ); + } + + const r = result.result; + return ( +
+ + + +
+ ); +} + +function Meta({ r }: { r: SandboxRunResponse }) { + const fields: { k: string; v: string | number | null }[] = [ + { k: "language", v: r.language }, + { k: "runtime", v: r.runtime_version ?? "?" }, + { k: "exit_code", v: r.exit_code }, + { k: "duration_ms", v: r.duration_ms }, + { k: "killed_by", v: r.killed_by }, + ]; + return ( +
    + {fields.map((f) => ( +
  • + {f.k}: {f.v === null ? "null" : String(f.v)} +
  • + ))} +
+ ); +} + +function Block({ + label, + value, + emptyHint, + tone, +}: { + label: string; + value: string; + emptyHint: string; + tone?: "warn"; +}) { + const trimmed = value ?? ""; + const isEmpty = trimmed.length === 0; + return ( +
+
{label}
+
+        {isEmpty ? emptyHint : trimmed}
+      
+
+ ); +} diff --git a/apps/web/src/app/playground/page.tsx b/apps/web/src/app/playground/page.tsx new file mode 100644 index 0000000..d1e21a2 --- /dev/null +++ b/apps/web/src/app/playground/page.tsx @@ -0,0 +1,18 @@ +import { PlaygroundClient } from "./PlaygroundClient"; + +export const dynamic = "force-dynamic"; + +export default function PlaygroundPage() { + return ( +
+
+

Playground

+

+ Run Python or TypeScript inside the LearnPro sandbox. Heavier UX (problem framing, hints, + submit-against-hidden-tests) lands later — this page is the bare runner. +

+
+ +
+ ); +} diff --git a/apps/web/src/app/playground/status.test.ts b/apps/web/src/app/playground/status.test.ts new file mode 100644 index 0000000..91c1c55 --- /dev/null +++ b/apps/web/src/app/playground/status.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { SandboxRunResponse } from "@learnpro/sandbox"; +import { runResultStatus, statusFor } from "./status"; + +function ok(over: Partial = {}): SandboxRunResponse { + return { + stdout: "", + stderr: "", + exit_code: 0, + duration_ms: 5, + killed_by: null, + language: "python", + runtime_version: "3.10.0", + ...over, + }; +} + +describe("statusFor", () => { + it("returns Idle when result is null", () => { + expect(statusFor(null).label).toBe("Idle"); + }); + + it("returns Error label when run helper failed (network/protocol)", () => { + const s = statusFor({ ok: false, status: 502, error: "api_unreachable" }); + expect(s.label).toBe("Error: api_unreachable"); + expect(s.color).toBe("#c33"); + }); + + it("returns OK when exit_code=0 and not killed", () => { + expect(statusFor({ ok: true, result: ok() }).label).toBe("OK"); + }); + + it("returns Killed (timeout) when killed_by=timeout", () => { + const s = statusFor({ + ok: true, + result: ok({ exit_code: null, killed_by: "timeout" }), + }); + expect(s.label).toBe("Killed (timeout)"); + expect(s.color).toBe("#c33"); + }); + + it("returns Exit N when exit_code!=0 and not killed", () => { + const s = statusFor({ ok: true, result: ok({ exit_code: 1 }) }); + expect(s.label).toBe("Exit 1"); + expect(s.color).toBe("#c33"); + }); +}); + +describe("runResultStatus (direct)", () => { + it("classifies OOM as Killed (memory)", () => { + expect(runResultStatus(ok({ exit_code: null, killed_by: "memory" })).label).toBe( + "Killed (memory)", + ); + }); + + it("classifies output-limit as Killed (output-limit)", () => { + expect(runResultStatus(ok({ killed_by: "output-limit" })).label).toBe("Killed (output-limit)"); + }); +}); diff --git a/apps/web/src/app/playground/status.ts b/apps/web/src/app/playground/status.ts new file mode 100644 index 0000000..b937483 --- /dev/null +++ b/apps/web/src/app/playground/status.ts @@ -0,0 +1,19 @@ +import type { SandboxRunResponse } from "@learnpro/sandbox"; +import type { RunSandboxResult } from "../../lib/run-sandbox"; + +export interface RunStatus { + label: string; + color: string; +} + +export function statusFor(result: RunSandboxResult | null): RunStatus { + if (result === null) return { label: "Idle", color: "#666" }; + if (!result.ok) return { label: `Error: ${result.error}`, color: "#c33" }; + return runResultStatus(result.result); +} + +export function runResultStatus(res: SandboxRunResponse): RunStatus { + if (res.killed_by !== null) return { label: `Killed (${res.killed_by})`, color: "#c33" }; + if (res.exit_code === 0) return { label: "OK", color: "#0a7" }; + return { label: `Exit ${res.exit_code ?? "?"}`, color: "#c33" }; +} diff --git a/apps/web/src/lib/run-sandbox.test.ts b/apps/web/src/lib/run-sandbox.test.ts new file mode 100644 index 0000000..ae15bec --- /dev/null +++ b/apps/web/src/lib/run-sandbox.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { runSandbox } from "./run-sandbox"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("runSandbox", () => { + it("POSTs to /api/sandbox/run with the request body and returns the parsed result", async () => { + const fakeFetch = vi.fn().mockResolvedValue( + jsonResponse({ + stdout: "hello\n", + stderr: "", + exit_code: 0, + duration_ms: 12, + killed_by: null, + language: "python", + runtime_version: "3.10.0", + }), + ); + + const out = await runSandbox( + { language: "python", code: "print('hello')" }, + { fetchImpl: fakeFetch as unknown as typeof fetch }, + ); + + expect(fakeFetch).toHaveBeenCalledWith( + "/api/sandbox/run", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + }), + ); + const call = fakeFetch.mock.calls[0]?.[1] as RequestInit; + expect(JSON.parse(call.body as string)).toEqual({ + language: "python", + code: "print('hello')", + }); + + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.result.stdout).toBe("hello\n"); + expect(out.result.exit_code).toBe(0); + } + }); + + it("returns ok:false with the upstream status and error code on a non-2xx response", async () => { + const fakeFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ error: "invalid_request", message: "bad code" }, 400)); + + const out = await runSandbox( + { language: "python", code: "" }, + { fetchImpl: fakeFetch as unknown as typeof fetch }, + ); + + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.status).toBe(400); + expect(out.error).toBe("invalid_request"); + expect(out.message).toBe("bad code"); + } + }); + + it("returns ok:false with error=network_error when fetch throws", async () => { + const fakeFetch = vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")); + + const out = await runSandbox( + { language: "python", code: "print(1)" }, + { fetchImpl: fakeFetch as unknown as typeof fetch }, + ); + + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.status).toBe(0); + expect(out.error).toBe("network_error"); + expect(out.message).toBe("connect ECONNREFUSED"); + } + }); + + it("falls back to error=request_failed when the response body has no error field", async () => { + const fakeFetch = vi.fn().mockResolvedValue(new Response("nope", { status: 502 })); + + const out = await runSandbox( + { language: "python", code: "print(1)" }, + { fetchImpl: fakeFetch as unknown as typeof fetch }, + ); + + expect(out.ok).toBe(false); + if (!out.ok) { + expect(out.status).toBe(502); + expect(out.error).toBe("request_failed"); + } + }); +}); diff --git a/apps/web/src/lib/run-sandbox.ts b/apps/web/src/lib/run-sandbox.ts new file mode 100644 index 0000000..b140283 --- /dev/null +++ b/apps/web/src/lib/run-sandbox.ts @@ -0,0 +1,58 @@ +import type { SandboxRunRequestInput, SandboxRunResponse } from "@learnpro/sandbox"; + +export interface RunSandboxOk { + ok: true; + result: SandboxRunResponse; +} + +export interface RunSandboxErr { + ok: false; + status: number; + error: string; + message?: string; +} + +export type RunSandboxResult = RunSandboxOk | RunSandboxErr; + +export interface RunSandboxOptions { + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +export async function runSandbox( + req: SandboxRunRequestInput, + opts: RunSandboxOptions = {}, +): Promise { + const f = opts.fetchImpl ?? fetch; + let res: Response; + try { + res = await f("/api/sandbox/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(req), + signal: opts.signal, + }); + } catch (err) { + return { + ok: false, + status: 0, + error: "network_error", + message: err instanceof Error ? err.message : String(err), + }; + } + + const body = (await res.json().catch(() => null)) as + | SandboxRunResponse + | { error: string; message?: string } + | null; + + if (!res.ok) { + const error = + body && typeof body === "object" && "error" in body ? body.error : "request_failed"; + const message = + body && typeof body === "object" && "message" in body ? body.message : undefined; + return { ok: false, status: res.status, error, ...(message ? { message } : {}) }; + } + + return { ok: true, result: body as SandboxRunResponse }; +} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index ff8b3c1..94cc25d 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -38,6 +38,7 @@ export { type SandboxKilledBy, type SandboxLanguage, type SandboxRunRequest, + type SandboxRunRequestInput, type SandboxRunResponse, type SandboxTelemetryEvent, type SandboxTelemetrySink, diff --git a/packages/sandbox/src/types.ts b/packages/sandbox/src/types.ts index 29a0889..6c7d194 100644 --- a/packages/sandbox/src/types.ts +++ b/packages/sandbox/src/types.ts @@ -24,6 +24,8 @@ export const SandboxRunRequestSchema = z.object({ .default(DEFAULT_OUTPUT_LIMIT_BYTES), }); export type SandboxRunRequest = z.infer; +// Pre-parse shape: callers pass this and zod fills in defaults at the boundary. +export type SandboxRunRequestInput = z.input; export const SandboxRunResponseSchema = z.object({ stdout: z.string(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc2c0e3..d9e78b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,9 +66,18 @@ importers: apps/web: dependencies: + '@learnpro/sandbox': + specifier: workspace:* + version: link:../../packages/sandbox '@learnpro/shared': specifier: workspace:* version: link:../../packages/shared + '@monaco-editor/react': + specifier: ^4.6.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 next: specifier: ^15.1.3 version: 15.5.15(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -78,6 +87,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.5(react@19.2.5) + zod: + specifier: ^3.24.1 + version: 3.25.76 devDependencies: '@next/eslint-plugin-next': specifier: ^15.1.3 @@ -1016,6 +1028,16 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -2424,6 +2446,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2828,6 +2853,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3613,6 +3641,17 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.52.2 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -5137,6 +5176,8 @@ snapshots: minimist@1.2.8: {} + monaco-editor@0.52.2: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -5598,6 +5639,8 @@ snapshots: stackback@0.0.2: {} + state-local@1.0.7: {} + std-env@3.10.0: {} stop-iteration-iterator@1.1.0: diff --git a/project/BOARD.md b/project/BOARD.md index 79ebc5b..f8b8303 100644 --- a/project/BOARD.md +++ b/project/BOARD.md @@ -1,6 +1,6 @@ # LearnPro Board -> **Last updated:** 2026-04-26 (STORY-008 done — TypeScript sandbox runner via Piston. Most of the wiring landed in STORY-007; this Story added TS-specific unit/integration/API tests proving `console.log('hello')` round-trips and timeout classification works for TS.) +> **Last updated:** 2026-04-26 (STORY-006 done — Monaco-based `/playground` page in `apps/web` with language selector + Run button + result panel; wiring path browser → Next.js Route Handler `/api/sandbox/run` → Fastify `/sandbox/run`. Re-scoped on pickup: WebSocket streaming split into [STORY-059](./stories/STORY-059-sandbox-streaming.md); Submit/hidden-tests deferred to [STORY-016](./stories/STORY-016-seed-bank.md); problem-language follow rewires when STORY-016 lands.) > **How to read this:** This is the live status of every Epic, Story, and Task in the project. Hand-maintained for now (a regenerator script lives in the v1 backlog). When you change an item's `status:` frontmatter, also update the row here in the same commit. --- @@ -22,7 +22,6 @@ Path A locked 2026-04-25. EPIC-019 (foundation) must land first since every othe |----|-------|------|------|----------|-----| | [STORY-005](stories/STORY-005-auth-and-onboarding.md) | Auth.js + bootstrap profile shell (re-scoped — onboarding split to STORY-053) | EPIC-002 | mvp | P0 | M | | [STORY-053](stories/STORY-053-conversational-onboarding-agent.md) | Conversational adaptive onboarding agent (replaces structured form; graceful exit + form fallback) | EPIC-004 | mvp | P0 | L | -| [STORY-006](stories/STORY-006-monaco-editor.md) | Monaco editor + run button + result panel | EPIC-002 | mvp | P0 | M | --- @@ -74,6 +73,7 @@ These stories were filed during EPIC-017 Phase C from the expanded idea catalog | [STORY-044](stories/STORY-044-pwa-baseline.md) | PWA baseline (manifest + service worker + offline shell) | EPIC-013 | v1 | P1 | M | | [STORY-045](stories/STORY-045-email-digests.md) | Email digest channel (weekly recap + grace-day notices) | EPIC-012 | v1 | P2 | S | | [STORY-046](stories/STORY-046-daily-weekly-plans.md) | Daily and weekly plan views (multi-horizon planning UI) | EPIC-006 | v1 | P1 | M | +| [STORY-059](stories/STORY-059-sandbox-streaming.md) | Live stdout/stderr streaming for sandbox runs (split from STORY-006 — Piston is request/response) | EPIC-003 | v1 | P1 | M | ## Backlog (v2 — filed via Phase C) @@ -90,10 +90,11 @@ These stories were filed during EPIC-017 Phase C from the expanded idea catalog ## Recently Done -STORY-008 (TypeScript sandbox runner via Piston) landed 2026-04-26. STORY-007 (Python sandbox runner via Piston) landed 2026-04-26 (PR #14) — first feature Story under EPIC-003. STORY-013 (learner profile schema) landed 2026-04-26 (PR #11) — first feature Story under EPIC-005. STORY-009 (LLM gateway) landed 2026-04-26 (PR #9) — first feature Story under EPIC-004. EPIC-019 (foundation) closed 2026-04-26 with STORY-052 (monorepo skeleton, PR #5) and STORY-057 (policy adapters, PR #7). GitHub repo + PR workflow landed 2026-04-25 (PR #1, STORY-058). EPIC-017 product grooming closed in full on 2026-04-25 (Phases A + B + C). EPIC-001 closed on 2026-04-25 (initial scaffolding commit `c1e17a1`). Phase A commit: `bbf7300`. +STORY-006 (Monaco editor + Run button + result panel) landed 2026-04-26 — first user-facing feature in `apps/web`. STORY-008 (TypeScript sandbox runner via Piston) landed 2026-04-26. STORY-007 (Python sandbox runner via Piston) landed 2026-04-26 (PR #14) — first feature Story under EPIC-003. STORY-013 (learner profile schema) landed 2026-04-26 (PR #11) — first feature Story under EPIC-005. STORY-009 (LLM gateway) landed 2026-04-26 (PR #9) — first feature Story under EPIC-004. EPIC-019 (foundation) closed 2026-04-26 with STORY-052 (monorepo skeleton, PR #5) and STORY-057 (policy adapters, PR #7). GitHub repo + PR workflow landed 2026-04-25 (PR #1, STORY-058). EPIC-017 product grooming closed in full on 2026-04-25 (Phases A + B + C). EPIC-001 closed on 2026-04-25 (initial scaffolding commit `c1e17a1`). Phase A commit: `bbf7300`. | ID | Title | Done | |----|-------|------| +| [STORY-006](stories/STORY-006-monaco-editor.md) | Monaco editor + Run button + result panel (`/playground` → Next.js proxy → Fastify `/sandbox/run`) | 2026-04-26 | | [STORY-008](stories/STORY-008-typescript-runner.md) | TypeScript sandbox runner via Piston (TS-specific unit/integration/API tests on top of STORY-007 infra) | 2026-04-26 | | [STORY-007](stories/STORY-007-python-runner.md) | Python sandbox runner via Piston (`SandboxProvider` + `PistonSandboxProvider` + `POST /sandbox/run`) | 2026-04-26 | | [STORY-014](stories/STORY-014-pgvector-schema.md) | pgvector IVFFlat index on `episodes.embedding` (column landed in STORY-013) | 2026-04-26 | diff --git a/project/stories/STORY-006-monaco-editor.md b/project/stories/STORY-006-monaco-editor.md index e90b8a7..f86c19c 100644 --- a/project/stories/STORY-006-monaco-editor.md +++ b/project/stories/STORY-006-monaco-editor.md @@ -2,38 +2,59 @@ id: STORY-006 title: Monaco editor + run button + result panel type: story -status: backlog +status: done priority: P0 estimate: M parent: EPIC-003 phase: mvp tags: [editor, monaco, ui] created: 2026-04-25 -updated: 2026-04-25 +updated: 2026-04-26 --- ## Description -The user-facing surface of the sandbox. Monaco editor (the same engine that powers VS Code — leverages users' existing muscle memory) embedded in the problem page, with a **Run** button that streams stdout/stderr into a result panel beneath, plus a **Submit** button that runs against hidden tests and surfaces pass/fail per case. +The user-facing surface of the sandbox. Monaco editor (the same engine that powers VS Code — leverages users' existing muscle memory) embedded in a `/playground` route, with a **Run** button that hits the API's `POST /sandbox/run` and shows stdout/stderr/exit_code/duration in a result panel beneath. Language switcher gates Python and TypeScript (MVP allow-list). -Includes language switching (Python/TS at MVP), syntax highlighting, basic IntelliSense, theme matching the app, and a sensible default font/size that respects the user's accessibility preferences. +This Story establishes the Monaco editor wiring + the Next.js → Fastify proxy that the rest of the UI work in EPIC-002 will build on. + +## MVP scope (this Story) + +- Monaco editor + language selector (Python / TypeScript). +- **Run** button → `fetch('/api/sandbox/run')` (Next.js Route Handler) → forwards to Fastify `POST /sandbox/run` → renders the response. +- Result panel: stdout, stderr, exit_code, duration_ms, killed_by, runtime_version. Color-cues for pass/fail. +- Keyboard nav baseline (focus trap inside editor + Esc to exit). + +## Out of MVP scope (split into follow-ups) + +- **Live token-by-token streaming via WebSocket** — Piston's HTTP API is request/response (the program runs to completion before any output is returned), so true streaming requires either polling or a different sandbox primitive. Filed as [STORY-059](./STORY-059-sandbox-streaming.md). +- **Submit button + hidden test runner** — depends on a problem entity + hidden test fixtures, which land in [STORY-016](./STORY-016-seed-bank.md). The Submit UX will be added there. +- **Editor language follows the problem language** — also requires the problem entity. Until then, the playground keeps a user-controlled language selector. Re-wires once [STORY-016](./STORY-016-seed-bank.md) lands. ## Acceptance criteria -- [ ] Monaco loads in <500ms on a warm cache. -- [ ] Run button streams stdout/stderr live (not just final output) via the `/realtime` WebSocket. -- [ ] Submit button runs hidden tests and renders a per-case pass/fail table with diffs on failure. -- [ ] Editor language mode follows the problem language (no manual switching). -- [ ] Editor is keyboard-navigable; tab traps inside the editor and Esc/Shift+F10 exit it (accessibility baseline). +- [x] Monaco editor renders on `/playground` and accepts code input. +- [x] Language selector toggles between `python` and `typescript`; Monaco language mode follows the selector. +- [x] Run button POSTs to `/api/sandbox/run`, which proxies to the Fastify API; the result panel renders stdout, stderr, exit_code, duration_ms, killed_by, and runtime_version. +- [x] Failed runs (non-zero exit, killed_by set, transport error) are visually distinct from successful runs. +- [x] Editor is keyboard-navigable: focus trap inside Monaco; Esc moves focus out (Monaco's built-in `editor.action.toggleTabFocusMode` + native blur). +- [x] Unit tests cover the Next.js Route Handler proxy + the browser-side `runSandbox` helper. +- [ ] Monaco loads in <500ms warm cache. *(Manual; not enforced in CI — perf budget tracking will land with the responsive-web Story (STORY-025).)* +- [ ] Live stdout/stderr streaming via WebSocket. *(Out of scope — see [STORY-059](./STORY-059-sandbox-streaming.md).)* +- [ ] Submit button + hidden tests. *(Out of scope — see [STORY-016](./STORY-016-seed-bank.md).)* +- [ ] Editor language follows the problem language. *(Out of scope — re-wires when [STORY-016](./STORY-016-seed-bank.md) lands.)* ## Dependencies -- Blocked by: STORY-007 (Python runner) or STORY-008 (TS runner) for end-to-end verification. +- Blocked by: STORY-007 (Python runner) ✅ + STORY-008 (TS runner) ✅. +- Blocks: nothing structural; STORY-016 will hook into the same `/playground` shell when it lands. ## Tasks -(To be created when work begins.) +(Tracked inline in the activity log.) ## Activity log - 2026-04-25 — created +- 2026-04-26 — picked up. Re-scoped to drop streaming + submit + problem-language ACs (filed STORY-059 for streaming; submit deferred to STORY-016). +- 2026-04-26 — done. Added Monaco-based `/playground` page (`apps/web/src/app/playground/`) with language selector (Python/TS), Run button, and a result panel that surfaces stdout / stderr / exit_code / duration_ms / killed_by / runtime_version. Wiring path: browser → Next.js Route Handler `POST /api/sandbox/run` (re-validates with `SandboxRunRequestSchema` + proxies to Fastify) → Fastify `POST /sandbox/run`. Added `SandboxRunRequestInput` type to `@learnpro/sandbox` (z.input vs. z.infer split) so callers don't need to pre-fill defaults. 17 new web tests (4 helper + 6 route handler + 7 status). Filed [STORY-059](./STORY-059-sandbox-streaming.md) for the deferred WebSocket-streaming AC. diff --git a/project/stories/STORY-059-sandbox-streaming.md b/project/stories/STORY-059-sandbox-streaming.md new file mode 100644 index 0000000..ee81ffa --- /dev/null +++ b/project/stories/STORY-059-sandbox-streaming.md @@ -0,0 +1,47 @@ +--- +id: STORY-059 +title: Live stdout/stderr streaming for sandbox runs (split from STORY-006) +type: story +status: backlog +priority: P1 +estimate: M +parent: EPIC-003 +phase: v1 +tags: [sandbox, websocket, streaming, ux] +created: 2026-04-26 +updated: 2026-04-26 +--- + +## Description + +[STORY-006](./STORY-006-monaco-editor.md) shipped the Monaco playground hitting `POST /sandbox/run` (request/response). For long-running programs, the user has to wait for the program to finish before seeing any output — fine for the seed problem bank (which targets <5s programs), but a poor UX once we add freeform exercises, debugging-style tracks, or larger projects ([STORY-048](./STORY-048-project-based-learning.md)). + +This Story adds a streaming primitive so the result panel can show stdout/stderr as they're emitted, not only after the process exits. + +The Piston HTTP API is fundamentally request/response (the program runs to completion before anything is returned), so streaming requires either: + +1. **Pin a different sandbox primitive in `SandboxProvider`** (e.g. spawn `docker run` directly with `--read-only --network none --cap-drop=ALL` and stream stdout/stderr lines back). This bypasses Piston for the streaming code path while keeping it as the fallback transport — `PistonSandboxProvider` continues to handle the non-streaming path. +2. **Or fake-stream by polling**: sandbox provider stays request/response; the Next.js Route Handler chunks the final output into newline-delimited tokens and emits them with artificial delay. Lower fidelity but zero infra change. + +Pick one in the kickoff conversation. Option 1 is the right long-term answer; Option 2 is a viable shortcut if there's no time before [STORY-048](./STORY-048-project-based-learning.md). + +## Acceptance criteria + +- [ ] `SandboxProvider` exposes a streaming method (e.g. `runStream(req): AsyncIterable`) alongside `run()`. +- [ ] API exposes the stream over either WebSocket or [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) (cheaper, sufficient for one-way stdout/stderr push). +- [ ] Web playground subscribes to the stream and appends to the result panel as chunks arrive (typed as `stdout` / `stderr` / `exit`). +- [ ] Hardening parity with the request/response path (`SandboxProvider` is the unit of trust — see [STORY-010](./STORY-010-sandbox-hardening.md)). +- [ ] Telemetry event still emits exactly once per run (on `exit` chunk). + +## Dependencies + +- Blocked by: STORY-010 (hardening — any new spawn primitive must pass the breakout suite). +- Related: STORY-048 (project-based learning — long-running programs are the main motivator). + +## Notes + +Filed during STORY-006 close-out (2026-04-26) when the WebSocket-streaming AC was descoped because Piston's HTTP API doesn't stream. + +## Activity log + +- 2026-04-26 — created (split from STORY-006).