diff --git a/examples/workflow-code-runner/.gitignore b/examples/workflow-code-runner/.gitignore index c7c1666..50dd20e 100644 --- a/examples/workflow-code-runner/.gitignore +++ b/examples/workflow-code-runner/.gitignore @@ -1,2 +1,5 @@ /.next /next-env.d.ts +.vercel +.env*.local +examples/workflow-code-runner/public/.well-known/ diff --git a/examples/workflow-code-runner/NOTES.md b/examples/workflow-code-runner/NOTES.md new file mode 100644 index 0000000..47e82da --- /dev/null +++ b/examples/workflow-code-runner/NOTES.md @@ -0,0 +1,4 @@ +# Problems with current implementation + +* webhook resumption doesn't work when running this locally because sandbox can't resume a localhost webhook +* deployment protection needs to be disabled for webhook resumption to happen in a preview branch diff --git a/examples/workflow-code-runner/app/api/run/route.ts b/examples/workflow-code-runner/app/api/run/route.ts index 25c54ee..1b63741 100644 --- a/examples/workflow-code-runner/app/api/run/route.ts +++ b/examples/workflow-code-runner/app/api/run/route.ts @@ -3,9 +3,9 @@ import { runCode } from "@/workflows/code-runner"; import { NextResponse } from "next/server"; export async function POST(request: Request) { - const { prompt } = await request.json(); + const { prompt, runtime } = await request.json(); - const run = await start(runCode, [prompt]); + const run = await start(runCode, [prompt, runtime]); return NextResponse.json({ runId: run.runId }); } @@ -13,12 +13,48 @@ export async function POST(request: Request) { export async function GET(request: Request) { const { searchParams } = new URL(request.url); const runId = searchParams.get("runId"); + const stream = searchParams.get("stream"); if (!runId) { return NextResponse.json({ error: "Missing runId" }, { status: 400 }); } const run = getRun(runId); + + // Stream stdout, stderr, or status as SSE + if (stream === "stdout" || stream === "stderr" || stream === "status") { + const readable = run.getReadable({ namespace: stream }); + const encoder = new TextEncoder(); + + const sseStream = new ReadableStream({ + async start(controller) { + try { + const reader = readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(value)}\n\n`), + ); + } + controller.enqueue(encoder.encode("event: done\ndata: {}\n\n")); + controller.close(); + } catch { + controller.close(); + } + }, + }); + + return new Response(sseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // Poll for status const status = await run.status; if (status === "completed") { diff --git a/examples/workflow-code-runner/app/components/code-block.tsx b/examples/workflow-code-runner/app/components/code-block.tsx new file mode 100644 index 0000000..2bc8823 --- /dev/null +++ b/examples/workflow-code-runner/app/components/code-block.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { codeToHtml } from "shiki"; + +export function CodeBlock({ + code, + lang = "javascript", +}: { + code: string; + lang?: string; +}) { + const [html, setHtml] = useState(""); + + useEffect(() => { + let cancelled = false; + codeToHtml(code, { + lang, + theme: "github-dark-default", + }).then((result) => { + if (!cancelled) setHtml(result); + }); + return () => { + cancelled = true; + }; + }, [code, lang]); + + if (!html) { + return ( +
+        {code.split("\n").map((line, i) => (
+          
+ + {i + 1} + + {line} +
+ ))} +
+ ); + } + + return ( +
+ ); +} diff --git a/examples/workflow-code-runner/app/components/terminal.tsx b/examples/workflow-code-runner/app/components/terminal.tsx new file mode 100644 index 0000000..3e685f2 --- /dev/null +++ b/examples/workflow-code-runner/app/components/terminal.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Ansi from "ansi-to-react"; +import { useEffect, useRef } from "react"; + +export function Terminal({ + title, + children, + variant = "default", +}: { + title: string; + children: string; + variant?: "default" | "error"; +}) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [children]); + + return ( +
+
+
+
+
+
+
+ {title} +
+
+        {children}
+      
+
+ ); +} diff --git a/examples/workflow-code-runner/app/globals.css b/examples/workflow-code-runner/app/globals.css index 9ca1e88..3a3f178 100644 --- a/examples/workflow-code-runner/app/globals.css +++ b/examples/workflow-code-runner/app/globals.css @@ -1,13 +1,17 @@ @import "tailwindcss"; :root { - --background: #0a0a0a; + --background: #000000; --foreground: #ededed; + --border: rgba(255, 255, 255, 0.1); + --muted: #484f58; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-border: var(--border); + --color-muted: var(--muted); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @@ -16,3 +20,19 @@ body { background: var(--background); color: var(--foreground); } + +/* Shiki line numbers */ +.code-block code { + counter-reset: step; +} + +.code-block code .line::before { + content: counter(step); + counter-increment: step; + display: inline-block; + width: 2rem; + margin-right: 1.5rem; + text-align: right; + color: var(--muted); + font-variant-numeric: tabular-nums; +} diff --git a/examples/workflow-code-runner/app/page.tsx b/examples/workflow-code-runner/app/page.tsx index 28fc06a..588a8ef 100644 --- a/examples/workflow-code-runner/app/page.tsx +++ b/examples/workflow-code-runner/app/page.tsx @@ -1,19 +1,57 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; +import type { RunCodeResult, Runtime } from "@/workflows/code-runner"; +import { CodeBlock } from "./components/code-block"; +import { Terminal } from "./components/terminal"; + +type Phase = + | "creating-sandbox" + | "generating" + | "fixing" + | "writing" + | "running" + | "stopping"; + +const PHASE_LABELS: Record = { + "creating-sandbox": "Creating sandbox", + generating: "Generating code", + fixing: "Fixing code", + writing: "Writing files to sandbox", + running: "Running in sandbox", + stopping: "Stopping sandbox", +}; + +const RUNTIMES: { value: Runtime; label: string }[] = [ + { value: "node24", label: "Node.js 24" }, + { value: "node22", label: "Node.js 22" }, + { value: "python3.13", label: "Python 3.13" }, + { value: "bash", label: "Bash" }, +]; + +const RUNTIME_LANG: Record = { + node24: "javascript", + node22: "javascript", + "python3.13": "python", + bash: "bash", +}; export default function Home() { const [prompt, setPrompt] = useState(""); - const [result, setResult] = useState<{ - code: string; - stdout: string; - stderr: string; - iterations: number; + const [runtime, setRuntime] = useState("node24"); + const [result, setResult] = useState(null); + const [code, setCode] = useState(""); + const [stdout, setStdout] = useState(""); + const [stderr, setStderr] = useState(""); + const [phase, setPhase] = useState<{ + phase: Phase; + attempt: number; } | null>(null); - const [status, setStatus] = useState<"idle" | "running" | "done" | "error">( - "idle", - ); + const [status, setStatus] = useState< + "idle" | "running" | "done" | "failed" | "error" + >("idle"); const [error, setError] = useState(""); + const abortRef = useRef(null); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -21,125 +59,227 @@ export default function Home() { setStatus("running"); setResult(null); + setCode(""); + setStdout(""); + setStderr(""); + setPhase(null); setError(""); + abortRef.current?.abort(); + abortRef.current = new AbortController(); + try { const res = await fetch("/api/run", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt }), + body: JSON.stringify({ prompt, runtime }), + signal: abortRef.current.signal, }); - if (!res.ok) { - throw new Error(await res.text()); - } + if (!res.ok) throw new Error(await res.text()); + + const { runId } = await res.json(); + + const stdoutSource = new EventSource( + `/api/run?runId=${runId}&stream=stdout`, + ); + const stderrSource = new EventSource( + `/api/run?runId=${runId}&stream=stderr`, + ); + const statusSource = new EventSource( + `/api/run?runId=${runId}&stream=status`, + ); - const data = await res.json(); + stdoutSource.onmessage = (e) => { + setStdout((prev) => prev + JSON.parse(e.data)); + }; + stderrSource.onmessage = (e) => { + setStderr((prev) => prev + JSON.parse(e.data)); + }; + statusSource.onmessage = (e) => { + const data = JSON.parse(JSON.parse(e.data)); + setPhase({ phase: data.phase, attempt: data.attempt }); + if (data.code) { + setCode(data.code); + } + }; - const pollResult = async (runId: string) => { + const cleanup = () => { + stdoutSource.close(); + stderrSource.close(); + statusSource.close(); + }; + + stdoutSource.addEventListener("done", cleanup); + stderrSource.addEventListener("done", cleanup); + statusSource.addEventListener("done", cleanup); + + const pollResult = async () => { const poll = await fetch(`/api/run?runId=${runId}`); - const pollData = await poll.json(); + const data = await poll.json(); - if (pollData.status === "completed") { - setResult(pollData.output); - setStatus("done"); - } else if (pollData.status === "failed") { - setError(pollData.error ?? "Workflow failed"); + if (data.status === "completed") { + cleanup(); + const output = data.output as RunCodeResult; + setResult(output); + setCode(output.code); + setPhase(null); + setStatus(output.success ? "done" : "failed"); + } else if (data.status === "failed") { + cleanup(); + setPhase(null); + setError("Workflow failed unexpectedly"); setStatus("error"); } else { - setTimeout(() => pollResult(runId), 1000); + setTimeout(pollResult, 1000); } }; - await pollResult(data.runId); + await pollResult(); } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; setError(err instanceof Error ? err.message : "Unknown error"); setStatus("error"); } } + const showPanes = code || stdout || stderr; + const EXT: Record = { + node24: "js", + node22: "js", + "python3.13": "py", + bash: "sh", + }; + const filename = `script.${EXT[runtime]}`; + return ( -
-
-
-

- Sandbox Code Runner -

-

- Describe a program and AI will generate and execute it in a sandbox. - If it fails, it automatically retries with the error context. -

+
+ {/* Top bar: prompt + status */} +
+
+
+

+ Sandbox Code Runner +

+

+ Describe a program and AI will generate and execute it in a + sandbox. +

+
+ +
+