From 6cc9bda3e29cb12d058e513496205c1eb2615890 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 2 Oct 2026 15:55:34 -0700 Subject: [PATCH 1/2] Improve scheduling and artifact runtime guidance --- e2e/execution-controls.test.ts | 96 ++++++++++++++++----------- e2e/gallery-drafts.test.ts | 1 + e2e/gallery-onboarding.test.ts | 32 +++++++++ package.json | 3 +- src/gallery/client.tsx | 10 ++- src/gallery/execution-controls.tsx | 102 +++++++++++++++++++---------- src/gallery/hosted.tsx | 33 +++++++++- src/mcp/guide.ts | 2 + src/sdk/hooks.test.ts | 17 ++++- src/sdk/hooks.ts | 20 ++++++ src/sdk/index.ts | 1 + 11 files changed, 241 insertions(+), 76 deletions(-) create mode 100644 e2e/gallery-onboarding.test.ts diff --git a/e2e/execution-controls.test.ts b/e2e/execution-controls.test.ts index 703c275..b722355 100644 --- a/e2e/execution-controls.test.ts +++ b/e2e/execution-controls.test.ts @@ -1,42 +1,64 @@ -import {mkdtemp,rm} from "node:fs/promises"; -import {tmpdir} from "node:os"; -import {join} from "node:path"; -import {expect,test} from "bun:test"; -import {chromium} from "playwright"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { chromium } from "playwright"; -test("schedule controls ignore unfinished request fields except when saving",async()=>{ - const directory=await mkdtemp(join(tmpdir(),"execution-controls-")); - const entry=join(directory,"fixture.tsx"); - await Bun.write(entry,`import {createElement} from ${JSON.stringify(Bun.resolveSync("react",import.meta.dir))};import {createRoot} from ${JSON.stringify(Bun.resolveSync("react-dom/client",import.meta.dir))};import {ExecutionControls} from ${JSON.stringify(new URL("../src/gallery/execution-controls.tsx",import.meta.url).pathname)};createRoot(document.getElementById("root")).render(createElement(ExecutionControls,{workspace:"default",name:"example"}));`); +test("schedule loads before editing, preserves request headers and ignores unfinished fields for controls", async () => { + const directory = await mkdtemp(join(tmpdir(), "execution-controls-")); + const entry = join(directory, "fixture.tsx"); + await Bun.write(entry, `import {createElement} from ${JSON.stringify(Bun.resolveSync("react", import.meta.dir))};import {createRoot} from ${JSON.stringify(Bun.resolveSync("react-dom/client", import.meta.dir))};import {ExecutionControls} from ${JSON.stringify(new URL("../src/gallery/execution-controls.tsx", import.meta.url).pathname)};createRoot(document.getElementById("root")).render(createElement(ExecutionControls,{workspace:"default",name:"example"}));`); let build; - try { build=await Bun.build({entrypoints:[entry],target:"browser"}); } finally { await rm(directory,{recursive:true,force:true}); } - expect(build.success).toBe(true); - const js=await build.outputs[0]!.text(); - const actions:string[]=[]; - let schedule={interval_seconds:3600,paused:false,next_run_at:Date.now()+3600000,request:{path:"/",method:"GET",headers:[]}}; - const server=Bun.serve({hostname:"127.0.0.1",port:0,async fetch(request){ - if(new URL(request.url).pathname==="/client.js")return new Response(js,{headers:{"content-type":"text/javascript"}}); - if(new URL(request.url).pathname==="/api/tools"){ - const call=await request.json() as {arguments:{action:string}};actions.push(call.arguments.action); - if(call.arguments.action==="pause")schedule={...schedule,paused:true}; - if(call.arguments.action==="resume")schedule={...schedule,paused:false}; - return Response.json({structuredContent:{schedule}}); + try { build = await Bun.build({ entrypoints: [entry], target: "browser" }); } + finally { await rm(directory, { recursive: true, force: true }); } + if (!build.success) throw new Error(build.logs.join("\n")); + const js = await build.outputs[0]!.text(); + type Arguments = { action: string; interval_seconds?: number; request?: { path: string; method: string; headers: [string, string][]; body?: string } }; + const calls: Arguments[] = []; + let schedule = { interval_seconds: 7200, paused: false, next_run_at: Date.now() + 7200000, request: { path: "/existing", method: "POST", headers: [["x-example", "first"], ["x-example", "second"]] as [string, string][], body: Buffer.from("prior body").toString("base64") } }; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + if (new URL(request.url).pathname === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (new URL(request.url).pathname === "/api/tools") { + const call = await request.json() as { arguments: Arguments }; calls.push(call.arguments); + if (call.arguments.action === "get") await gate; + if (call.arguments.action === "pause") schedule = { ...schedule, paused: true }; + if (call.arguments.action === "resume") schedule = { ...schedule, paused: false }; + if (call.arguments.action === "set") schedule = { ...schedule, interval_seconds: call.arguments.interval_seconds!, request: { ...call.arguments.request!, body: call.arguments.request!.body ?? "" } }; + return Response.json({ structuredContent: { schedule } }); } - return new Response('
',{headers:{"content-type":"text/html"}}); - }}); - const browser=await chromium.launch({headless:true}); - try{ - const page=await browser.newPage();await page.goto(server.url.href); - await page.getByText("Schedule",{exact:true}).click(); + return new Response('
', { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); await page.goto(server.url.href); + await page.getByText("Schedule", { exact: true }).click(); + await page.getByText("Loading schedule…", { exact: true }).waitFor(); + expect(await page.getByRole("button", { name: "Save schedule", exact: true }).isEnabled()).toBe(false); + expect(await page.getByLabel("Schedule path").isEnabled()).toBe(false); + release(); + await page.getByText("Next run:", { exact: false }).waitFor(); + expect(await page.getByLabel("Schedule path").inputValue()).toBe("/existing"); + expect(await page.getByLabel("Schedule interval", { exact: true }).inputValue()).toBe("2"); + expect(await page.getByLabel("Schedule interval unit").inputValue()).toBe("hours"); + expect(await page.getByLabel("Schedule body").inputValue()).toBe("prior body"); + await page.getByLabel("Schedule interval unit").selectOption("minutes"); + expect(await page.getByLabel("Schedule interval", { exact: true }).inputValue()).toBe("120"); + await page.getByLabel("Schedule interval", { exact: true }).fill("30"); + expect(await page.getByText("Next run if saved now:", { exact: false }).innerText()).toContain("(UTC)"); + await page.getByRole("button", { name: "Save schedule", exact: true }).click(); + await page.getByRole("button", { name: "Pause schedule", exact: true }).click(); + expect(calls.find(call => call.action === "set")).toMatchObject({ interval_seconds: 1800, request: { path: "/existing", headers: [["x-example", "first"], ["x-example", "second"]] } }); await page.getByLabel("Schedule headers").fill("{"); - await page.getByRole("button",{name:"Load schedule",exact:true}).click(); - await page.getByText("Next run:",{exact:false}).waitFor(); - await page.getByLabel("Schedule headers").fill("{"); - await page.getByRole("button",{name:"Pause schedule",exact:true}).click(); - await page.getByRole("button",{name:"Resume schedule",exact:true}).click(); - await page.getByRole("button",{name:"Run schedule now",exact:true}).click(); - await page.getByRole("button",{name:"Save schedule",exact:true}).click(); + await page.getByRole("button", { name: "Resume schedule", exact: true }).click(); + await page.getByRole("button", { name: "Run schedule now", exact: true }).click(); + await page.getByRole("button", { name: "Save schedule", exact: true }).click(); await page.getByRole("alert").waitFor(); - expect(actions).toEqual(["get","pause","resume","run_now"]); - }finally{await browser.close();server.stop(true);} -},30000); + expect(calls.map(call => call.action)).toEqual(["get", "set", "pause", "resume", "run_now"]); + await page.getByLabel("Schedule headers").fill('{"x-new":"value"}'); + await page.getByRole("button", { name: "Save schedule", exact: true }).click(); + await page.getByRole("button", { name: "Pause schedule", exact: true }).click(); + expect(calls.filter(call => call.action === "set").at(-1)!.request!.headers).toEqual([["x-new", "value"]]); + } finally { release(); await browser.close(); server.stop(true); } +}, 30000); diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts index 7024fa7..3236477 100644 --- a/e2e/gallery-drafts.test.ts +++ b/e2e/gallery-drafts.test.ts @@ -68,6 +68,7 @@ test("gallery retains complete unsaved projects and new scripts and offers confl await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("old"); await page.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor(); expect(await client.innerText()).toBe("historical client"); + expect(await page.locator(".live-data-note").innerText()).toContain("Historical code uses the current database and files and can change them"); await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("working"); expect(await client.innerText()).toBe("unsaved client"); await page.getByRole("button", { name: "Save and deploy", exact: true }).click(); diff --git a/e2e/gallery-onboarding.test.ts b/e2e/gallery-onboarding.test.ts new file mode 100644 index 0000000..6579b16 --- /dev/null +++ b/e2e/gallery-onboarding.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { chromium } from "playwright"; + +test("empty hosted library supplies copyable deployment MCP URL and starter prompt", async () => { + const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" }); + if (!build.success) throw new Error(build.logs.join("\n")); + const js = await build.outputs[0]!.text(); + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (path === "/api/session") return Response.json({}); + if (path === "/api/gallery") return Response.json({ workspace: "my-project", artifacts: [], capabilities: { links: true, scripts: true } }); + return new Response('
', { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ permissions: ["clipboard-read", "clipboard-write"] }); + const page = await context.newPage(); + await page.goto(new URL("?library=team", server.url).href); + const url = await page.getByLabel("Deployment MCP URL").inputValue(); + expect(url).toBe(new URL("/mcp?workspace=my-project&library=team", server.url).href); + await page.getByRole("button", { name: "Copy MCP URL", exact: true }).click(); + await page.getByText("MCP URL copied", { exact: true }).waitFor(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url); + const prompt = await page.getByLabel("Starter prompt").inputValue(); + expect(prompt).toContain("artifact_guide"); + expect(prompt).toContain("private access"); + await page.getByRole("button", { name: "Copy starter prompt", exact: true }).click(); + await page.getByText("Starter prompt copied", { exact: true }).waitFor(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(prompt); + } finally { await browser.close(); server.stop(true); } +}, 30000); diff --git a/package.json b/package.json index 2eb9bff..e67ac63 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "src/viewer.ts", "src/localProject.ts", "cloudflare/project.ts", + "cloudflare/schedule.ts", "src/gallery/execution-controls.tsx", "src/gallery/project-editor.tsx", "src/gallery/source-editor.tsx", @@ -106,7 +107,7 @@ "mcp": "bun run src/cli.ts mcp", "test": "bun test src scripts/prepare-plugins.test.ts examples/runner-status --timeout 30000", "test:e2e": "bun test e2e/herdr.e2e.test.ts", - "test:mcp-ui": "bun test e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", + "test:mcp-ui": "bun test e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", "typecheck": "tsc --noEmit && tsc -p examples/runner-status/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.local.json --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 3828ff9..19645ab 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import { authClient, signInUrl } from "../auth/client-api"; import type { GalleryArtifact, GalleryData } from "./types"; import { ExecutionControls } from "./execution-controls"; -import { ArtifactSourcePanel, LinkSettings, ScriptPanel, liveRevisionLabel, type ScriptView } from "./hosted"; +import { AgentOnboarding, ArtifactSourcePanel, LinkSettings, ScriptPanel, liveRevisionLabel, type ScriptView } from "./hosted"; import { artifactFileTransferUrl } from "../sdk/files"; import { RemixPanel } from "./remix"; import { Select } from "./select"; @@ -130,6 +130,12 @@ const styles = ` .save-feedback p { margin: 0 0 6px; } .save-feedback button { height: auto; text-align: left; text-decoration: underline; } .save-feedback pre { white-space: pre-wrap; overflow-wrap: anywhere; } + .agent-onboarding { max-width: 680px; padding: 32px; } + .agent-onboarding label { display: block; margin-top: 16px; } + .agent-onboarding input, .agent-onboarding textarea { display: block; width: 100%; background: var(--raised); } + .agent-onboarding textarea { min-height: 100px; } + .live-data-note { padding: 8px 16px; margin: 0; border-bottom: 1px solid var(--line); color: var(--muted); } + .artifact-execution fieldset { border: 0; padding: 0; margin: 0; min-width: 0; } .source-actions .source-readonly { margin-right: auto; } .save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; } .save-conflict button { border-color: var(--line); margin-right: 8px; } @@ -693,12 +699,14 @@ function App() { {sortedVersions.map(version => )} : null} + {!creatingScript && selectedArtifact?.kind !== "script" && selectedArtifact && resolvedVersion !== "working" && gallery?.capabilities?.links ?

Live data · Historical code uses the current database and files and can change them. Restore deploys this code while keeping current data.

: null} {showMove && !creatingScript && selectedArtifact && gallery?.libraryScope && gallery.capabilities?.moves ?
setShowMove(false)} />
: null} {remixing && !creatingScript && selectedArtifact && resolvedVersion ?
setRemixing(false)} onSaved={async name => { createdRemix.current = {name, workspace:selectedArtifact.workspace, kind:selectedArtifact.kind ?? "artifact"}; await loadGallery(); setSelectedVersion("working"); setQuery(""); setKindFilter("all"); setRemixing(false); }} />
: null} {!creatingScript && selectedArtifact && gallery?.capabilities?.links ? : null}
{creatingScript ? setCreatingScript(false)} onSaved={async name => { createdScriptName.current = name; await loadGallery(); setCreatingScript(false); setSelectedVersion("working"); setTab("source"); setQuery(""); setKindFilter("all"); }} /> : selectedArtifact?.kind === "script" ? { setSelectedVersion("working"); await loadGallery(); }} /> + : !selectedArtifact && !loading && gallery?.capabilities?.links ? : !selectedArtifact ?

{loading ? "Loading your library…" : "Your library"}

{loading ? "Your saved artifacts will appear shortly." : "Select an artifact or script to open it."}

: !resolvedVersion ?

No readable version

This artifact has no saved source available to preview.

: <> diff --git a/src/gallery/execution-controls.tsx b/src/gallery/execution-controls.tsx index 97dd908..df99444 100644 --- a/src/gallery/execution-controls.tsx +++ b/src/gallery/execution-controls.tsx @@ -1,44 +1,80 @@ -import {Select} from "./select"; -import {useState} from "react"; -import {galleryTool} from "./hosted"; +import { useEffect, useState } from "react"; +import { Select } from "./select"; +import { encodeRequestBody, galleryTool, parseRequestHeaders } from "./hosted"; +import { nextOccurrence, normalizeTiming } from "../../cloudflare/schedule"; -type Schedule = {interval_seconds?:number;cron?:string;timezone?:string;paused:boolean;next_run_at:number|null;request:{path:string;method:string;headers:[string,string][];body?:string}}; -type Run = {id:string;revision:string;trigger:string;started_at:string;duration_ms:number|null;status:string;http_status:number|null}; -const unpack=(value:unknown):any=>typeof value==="string"?JSON.parse(value):value; -export function ExecutionControls({workspace,name,kind="script"}:{workspace:string;name:string;kind?:"script"|"artifact"}) { - const [schedule,setSchedule]=useState(null),[loaded,setLoaded]=useState(false); - const [mode,setMode]=useState("interval"),[interval,setInterval]=useState("3600"),[cron,setCron]=useState("0 * * * *"),[timezone,setTimezone]=useState("UTC"); - const [path,setPath]=useState("/"),[method,setMethod]=useState("GET"),[headers,setHeaders]=useState("[]"),[body,setBody]=useState(""); - const [runs,setRuns]=useState([]),[logs,setLogs]=useState(""),[error,setError]=useState(""),[busy,setBusy]=useState(false); - async function perform(work:()=>Promise) {setBusy(true);setError("");try{await work();}catch(e){setError(e instanceof Error?e.message:String(e));}finally{setBusy(false);}} - async function control(action:string) { - const request=action==="set"?{path,method,headers:JSON.parse(headers),...(body?{body:btoa(Array.from(new TextEncoder().encode(body),b=>String.fromCharCode(b)).join(""))}:{})}:undefined; - const result=unpack(await galleryTool(workspace,`${kind}_schedule`,{name,action,...(action==="set"?{...(mode==="interval"?{interval_seconds:Number(interval)}:{cron,timezone}),request}:{})})); - setSchedule(result.schedule);setLoaded(true); - if(action==="get"&&result.schedule){const s=result.schedule as Schedule;setMode(s.cron?"cron":"interval");setInterval(String(s.interval_seconds??3600));setCron(s.cron??"0 * * * *");setTimezone(s.timezone??"UTC");setPath(s.request.path);setMethod(s.request.method);setHeaders(JSON.stringify(s.request.headers));setBody(s.request.body?new TextDecoder().decode(Uint8Array.from(atob(s.request.body),c=>c.charCodeAt(0))):"");} +type Schedule = { interval_seconds?: number; cron?: string; timezone?: string; paused: boolean; next_run_at: number | null; request: { path: string; method: string; headers: [string, string][]; body?: string } }; +type Run = { id: string; revision: string; trigger: string; started_at: string; duration_ms: number | null; status: string; http_status: number | null }; +const unpack = (value: unknown): { schedule: Schedule | null; runs: Run[] } => typeof value === "string" ? JSON.parse(value) : value as { schedule: Schedule | null; runs: Run[] }; +const units = { seconds: 1, minutes: 60, hours: 3600, days: 86400 }; +type Unit = keyof typeof units; + +export function ExecutionControls({ workspace, name, kind = "script" }: { workspace: string; name: string; kind?: "script" | "artifact" }) { + const [open, setOpen] = useState(false); + const [schedule, setSchedule] = useState(null); + const [loaded, setLoaded] = useState(false); + const [mode, setMode] = useState("interval"); + const [interval, setInterval] = useState("1"), [unit, setUnit] = useState("hours"); + const [cron, setCron] = useState("0 * * * *"), [timezone, setTimezone] = useState("UTC"); + const [path, setPath] = useState("/"), [method, setMethod] = useState("GET"); + const [headers, setHeaders] = useState("{}"), [originalHeaders, setOriginalHeaders] = useState<[string, string][] | null>(null); + const [body, setBody] = useState(""); + const [runs, setRuns] = useState([]), [logs, setLogs] = useState(""); + const [error, setError] = useState(""), [busy, setBusy] = useState(false); + + async function perform(work: () => Promise) { + setBusy(true); setError(""); + try { await work(); } catch (error) { setError(error instanceof Error ? error.message : String(error)); } + finally { setBusy(false); } + } + function timing() { return normalizeTiming(mode === "interval" ? { interval_seconds: Number(interval) * units[unit] } : { cron, timezone }); } + async function control(action: string) { + const request = action === "set" ? { path, method, headers: originalHeaders ?? parseRequestHeaders(headers), ...(method !== "GET" && method !== "HEAD" && body ? { body: encodeRequestBody(body) } : {}) } : undefined; + if (request && !path.startsWith("/")) throw new Error("Path must start with /."); + const result = unpack(await galleryTool(workspace, `${kind}_schedule`, { name, action, ...(action === "set" ? { ...timing(), request } : {}) })); + setSchedule(result.schedule); setLoaded(true); + if (action === "get") { + const current = result.schedule; + const seconds = current?.interval_seconds ?? 3600; + const nextUnit = (["days", "hours", "minutes", "seconds"] as Unit[]).find(value => seconds % units[value] === 0)!; + setMode(current?.cron ? "cron" : "interval"); setUnit(nextUnit); setInterval(String(seconds / units[nextUnit])); + setCron(current?.cron ?? "0 * * * *"); setTimezone(current?.timezone ?? "UTC"); + setPath(current?.request.path ?? "/"); setMethod(current?.request.method ?? "GET"); + setOriginalHeaders(current?.request.headers ?? null); + setHeaders(JSON.stringify(Object.fromEntries(current?.request.headers ?? []), null, 2)); + setBody(current?.request.body ? new TextDecoder().decode(Uint8Array.from(atob(current.request.body), character => character.charCodeAt(0))) : ""); + } } + useEffect(() => { if (open && !loaded) void perform(() => control("get")); }, [open]); + let preview = ""; + if (loaded) { try { preview = `Next run if saved now: ${new Date(nextOccurrence(timing())).toISOString()} (UTC)`; } catch (error) { preview = error instanceof Error ? error.message : String(error); } } + const duplicates = originalHeaders && new Set(originalHeaders.map(([key]) => key.toLowerCase())).size < originalHeaders.length; return <> - {error?

{error}

:null} -
Schedule - - {loaded?

{schedule?(schedule.paused?"Paused":`Next run: ${new Date(schedule.next_run_at!).toISOString()} (UTC)`):"No schedule configured"}

:null} -
{event.preventDefault();void perform(()=>control("set"));}}> - - {mode==="interval"?:<>} - - -