diff --git a/quc/src/page.js b/quc/src/page.js new file mode 100644 index 0000000..59acd08 --- /dev/null +++ b/quc/src/page.js @@ -0,0 +1,233 @@ +// The one page: start screen at /, the chair (live run, or the replay with its scrubber) at /run/:id. +// The browser code lives in real functions below and is inlined with toString(), so there is nothing to escape and no build. +import { validate } from "./validate.js"; + +export const CHAIR = ` + + +`; + +const CSS = ` +:root{--bg:#f7f5f0;--fg:#1b1a17;--mute:#6d6b64;--line:#e2dfd6;--card:#fffdf8;--accent:#7a3cff;--accent-fg:#fff;--add:#e3f6e8;--add-fg:#14532d;--del:#fbe7e7;--del-fg:#7f1d1d;--warn:#b42318;--serif:ui-serif,Georgia,"Times New Roman",serif;--sans:system-ui,-apple-system,"Segoe UI",sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +@media (prefers-color-scheme:dark){:root{--bg:#141312;--fg:#ecebe6;--mute:#9a988f;--line:#2b2a27;--card:#1b1a18;--accent:#a98bff;--accent-fg:#141312;--add:#12301d;--add-fg:#b6f0c6;--del:#3a1616;--del-fg:#ffc2c2;--warn:#ff8a80}} +*{box-sizing:border-box} +body{margin:0;min-height:100vh;min-height:100dvh;background:var(--bg);color:var(--fg);font:16px/1.55 var(--sans)} +a{color:var(--accent)} +header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);max-width:1200px;margin:0 auto} +header a.brand{font:600 22px var(--serif);color:var(--fg);text-decoration:none} +main{max-width:1200px;margin:0 auto;padding:24px 16px 180px} +h1{font:600 clamp(30px,6vw,52px)/1.08 var(--serif);margin:0 0 12px;letter-spacing:-.01em} +h2{font:600 20px var(--serif);margin:0 0 8px} +p{margin:0 0 14px}.mute{color:var(--mute)} +[hidden]{display:none!important} +form{max-width:560px;margin-top:20px} +label{display:block;font-weight:600;margin-bottom:4px} +input,button,textarea{font:inherit;color:var(--fg)} +input[type=text],input[type=url]{width:100%;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card)} +input[aria-invalid=true]{border-color:var(--warn)} +button{padding:10px 16px;border-radius:8px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-weight:600} +button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)} +button.safeword{background:#c62828;border-color:#c62828;color:#fff;font-size:17px;padding:12px 20px} +button:disabled{opacity:.5;cursor:default} +.field{margin-bottom:16px}.err{color:var(--warn);font-size:14px;margin:4px 0 0;min-height:1em} +.notice{padding:10px 12px;border-left:3px solid var(--accent);background:var(--card);font-size:14px} +.rules{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:28px 0;max-width:900px} +.rules div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px} +.rules b{display:block;font-family:var(--serif);font-size:18px;margin-bottom:4px} +.bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px;margin-bottom:16px} +.status{font-size:14px;padding:4px 10px;border-radius:99px;background:var(--card);border:1px solid var(--line)} +.status[data-s=running]::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:#2a9d4a;margin-right:6px;animation:pulse 1.2s infinite} +@keyframes pulse{50%{opacity:.25}} +.grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.5fr);gap:16px} +@media (max-width:820px){.grid{grid-template-columns:minmax(0,1fr)}} +.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;min-width:0} +.feed{display:flex;flex-direction:column;gap:10px;max-height:62vh;overflow:auto} +.say{border-left:3px solid var(--accent);padding:2px 0 2px 10px;font-family:var(--serif);font-size:17px} +.sys{font-size:13px;color:var(--mute)} +.note{align-self:flex-end;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:6px 10px;font-size:14px;max-width:85%} +.unlock{font-size:13px;font-weight:600;color:var(--accent)} +.files{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px} +.files button{font:12px var(--mono);padding:4px 8px;font-weight:400} +.files button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)} +pre.diff{margin:0;font:12.5px/1.45 var(--mono);overflow:auto;max-height:62vh;white-space:pre} +pre.diff span{display:block;padding:0 8px} +pre.diff .a{background:var(--add);color:var(--add-fg)}pre.diff .d{background:var(--del);color:var(--del-fg)}pre.diff .h{color:var(--mute)} +.commit{font:12px var(--mono);color:var(--mute);margin:0 0 8px} +.saybox{display:flex;gap:8px;margin-top:12px}.saybox input{flex:1} +.scrub{display:flex;align-items:center;gap:10px;margin:0 0 16px}.scrub input{flex:1;accent-color:var(--accent)} +.after{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:0 0 16px} +.after div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px} +.after b{display:block;font:600 24px var(--serif)} +.chair{position:fixed;right:0;bottom:0;width:clamp(84px,18vw,150px);color:var(--fg);pointer-events:none;opacity:.85} +`; + +// ---------- browser code ---------- + +function client(validate) { + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); + const api = async (path, opts = {}) => { + const r = await fetch(path, { ...opts, headers: { "content-type": "application/json", ...(opts.headers || {}) } }); + const j = await r.json().catch(() => ({})); + if (!r.ok) throw Object.assign(new Error(j.error || r.statusText), { status: r.status, body: j }); + return j; + }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + fetch("/chair.svg").then((r) => r.text()).then((s) => { const d = document.createElement("div"); d.className = "chair"; d.innerHTML = s; d.firstElementChild.setAttribute("aria-hidden", "true"); document.body.append(d); }).catch(() => {}); + + const m = location.pathname.match(/^\/run\/([\w-]+)$/); + if (!m) return startScreen(); + return chair(m[1]); + + function startScreen() { + $("start").hidden = false; + $("form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + const repo = $("repo").value.trim(), task = $("task").value.trim(), errs = validate(repo, task); + for (const k of ["repo", "task"]) { $(k + "-err").textContent = errs[k] || ""; $(k).setAttribute("aria-invalid", !!errs[k]); } + if (Object.keys(errs).length) return $(errs.repo ? "repo" : "task").focus(); + const b = $("go"); b.disabled = true; b.textContent = "Taking your seat…"; + try { + const j = await api("/api/runs", { method: "POST", body: JSON.stringify({ repo, task }) }); + location.assign("/run/" + j.id); + } catch (e) { + if (e.body?.id) return location.assign("/run/" + e.body.id); + $("task-err").textContent = e.message; b.disabled = false; b.textContent = "Hand it over"; + } + }); + } + + function chair(id) { + $("run").hidden = false; + document.title = "quc: run " + id; + let run = null, owner = false, events = [], at = 0, live = false, pick = null; + + const render = () => { + if (!run) return; + const shown = events.slice(0, at); + $("run-task").textContent = run.task; + $("run-repo").textContent = run.repo; $("run-repo").href = run.repo; + const st = run.status === "ended" ? { finished: "Finished", safeworded: "Safeworded", unlocked: "You took the wheel" }[run.outcome] : run.status === "queued" ? "Taking over…" : "Working on your repo"; + $("status").textContent = st; $("status").dataset.s = run.status; + $("safeword").hidden = !(owner && run.status !== "ended"); + $("saybox").hidden = !(owner && run.status !== "ended"); + $("level").textContent = owner && run.status !== "ended" ? ["Caged. You can only watch.", "Notch 1: you may leave notes.", "Notch 2: name a file path to veto it.", ""][run.level || 0] : ""; + $("feed").innerHTML = shown.filter((e) => e.kind !== "diff" && e.kind !== "commit").map((e) => + e.kind === "commentary" ? `
${esc(e.data.text)}
` + : e.kind === "note" ? `
You: ${esc(e.data.text)}
` + : e.kind === "unlock" ? `
Unlocked notch ${e.data.level}
` + : `
${esc(e.data.text)}
`).join("") || `
Nothing yet. Sit.
`; + $("feed").scrollTop = $("feed").scrollHeight; + const diffs = shown.filter((e) => e.kind === "diff"); + const latest = {}; + for (const d of diffs) latest[d.data.path] = d; + const paths = Object.keys(latest); + const focus = pick && latest[pick] ? latest[pick] : diffs[diffs.length - 1]; + $("files").innerHTML = paths.map((p) => ``).join(""); + const commit = focus && shown.find((e) => e.kind === "commit" && e.seq === focus.seq + 1); + $("commit").textContent = commit ? "commit: " + commit.data.message : focus?.data.reverted ? "reverted on your veto" : ""; + $("diff").innerHTML = focus ? focus.data.patch.split("\n").map((l) => `${esc(l) || " "}`).join("") + : `No changes yet. It hasn't touched anything. Yet.`; + const ended = run.status === "ended"; + $("scrub").hidden = !(ended || !owner) || !events.length; + $("scrub-range").max = events.length; $("scrub-range").value = at; + $("scrub-at").textContent = `${at} / ${events.length}`; + $("after").hidden = !ended; + if (ended) { + $("a-files").textContent = run.stats.files; $("a-add").textContent = "+" + run.stats.added; $("a-del").textContent = "-" + run.stats.removed; + $("a-patch").hidden = run.outcome === "safeworded" || !run.stats.files; + $("a-patch").href = `/api/runs/${id}/patch`; + } + }; + + $("files").addEventListener("click", (e) => { const p = e.target.closest("button")?.dataset.p; if (p) { pick = p; render(); } }); + $("scrub-range").addEventListener("input", (e) => { at = Number(e.target.value); live = false; render(); }); + $("share").addEventListener("click", async () => { try { await navigator.clipboard.writeText(location.href); $("share").textContent = "Link copied"; } catch { prompt("Copy the replay link", location.href); } }); + $("safeword").addEventListener("click", async () => { + $("safeword").disabled = true; + try { await api(`/api/runs/${id}/safeword`, { method: "POST", body: "{}" }); } finally { await refresh(); } + }); + $("saybox").addEventListener("submit", async (e) => { + e.preventDefault(); + const text = $("say").value.trim(); if (!text) return; + $("say").value = ""; + try { await api(`/api/runs/${id}/say`, { method: "POST", body: JSON.stringify({ text }) }); } catch (err) { $("say-err").textContent = err.message; } + await refresh(); + }); + + async function refresh() { + const j = await api(`/api/runs/${id}?after=${events.length}`); + const follow = at === events.length; + run = j.run; owner = j.owner; + events = events.concat(j.events.filter((e) => e.seq > events.length)); + if (follow || live) at = events.length; + render(); + } + + (async () => { + try { await refresh(); } catch (e) { $("run-task").textContent = e.status === 404 ? "No such run." : e.message; return; } + live = true; + if (run.status !== "ended") { + (async () => { while (run.status !== "ended") { await sleep(2000); await refresh().catch(() => {}); } })(); + while (owner && run.status !== "ended") { + try { await api(`/api/runs/${id}/step`, { method: "POST", body: "{}" }); } catch { await sleep(2000); } + await refresh().catch(() => {}); + await sleep(300); + } + } else if (events.length) { + at = 0; render(); + for (let i = 1; i <= events.length && !document.hidden; i++) { if (at !== i - 1) break; at = i; render(); await sleep(350); } + } + })(); + } +} + +export const PAGE = ` + +quc + + +
qucyou sit, it codes
+
+ + +
+`; diff --git a/quc/src/worker.js b/quc/src/worker.js index 3112a45..c005c47 100644 --- a/quc/src/worker.js +++ b/quc/src/worker.js @@ -1,114 +1,112 @@ -// quc: app shell + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs). -// GET / the shell (start form) GET /run/:id the shell (chair view) GET /chair.svg the chair-in-the-corner motif -// GET /api/health {ok, rev} anything else 404 -// Fonts: system stacks on purpose (no webfont files, no third-party requests). shortcut: swap in self-hosted fonts when the brand picks them. +// quc: page + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs). +// GET /, /run/:id the page (start screen, or the chair / replay) +// GET /chair.svg the chair-in-the-corner motif +// GET /api/health {ok, rev, live} live: false when no model key is bound (offline demo answers) +// POST /api/runs {repo, task} -> {id} sets the visitor cookie +// GET /api/runs/:id?after=N {run, events, owner} anyone with the link +// POST /api/runs/:id/step advance one step owner only +// POST /api/runs/:id/safeword stop, throw the work away owner only +// POST /api/runs/:id/say {text}: talk from the chair owner only +// GET /api/runs/:id/patch the run's changes as a unified diff +// Bindings: KV (a KV namespace), VENICE_API_KEY (secret; without it the agent answers offline), REV, CODER_MODEL, PERSONA_MODEL, +// RUNS_PER_DAY (per visitor, default 5), RUNS_PER_DAY_ALL (default 300). +import { PAGE, CHAIR } from "./page.js"; +import { createRun, getRun, getEvents, publicRun, memoryStore } from "./runs.js"; +import { step, safeword, say, patch } from "./engine.js"; -export const CHAIR = ` - - -`; +export { validate } from "./validate.js"; +export { PAGE, CHAIR }; -// Inlined into the page via toString(), so the browser and the tests run the same code. Returns {repo?, task?} errors; {} is valid. -export function validate(repo, task) { - const e = {}; - if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+?(\.git)?\/?$/.test(repo)) e.repo = "Use a public GitHub repo URL, like https://github.com/owner/name."; - if (task.length < 10) e.task = "Say a bit more: at least 10 characters."; - else if (task.length > 200) e.task = "Keep it to one line: 200 characters at most."; - return e; +const COOKIE = "quc_v"; +const json = (body, status = 200, headers = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } }); +const fallback = memoryStore(); // dev and tests only: a deployed Worker always has KV bound + +function visitorOf(req) { + const m = (req.headers.get("cookie") || "").match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]{16,64})`)); + return m ? m[1] : null; } -const NOTICE = "Replays are public. Anyone with the link to a run can watch it, so don't point this at anything you wouldn't show a stranger."; - -export const PAGE = ` - -quc - - -
quc
-
-
-

Leave it with the chair.

-

Point quc at a public GitHub repo, say in one line what you want done, and go do something else. It works in the corner, quietly, and you can watch the replay.

-
-
- -
-
- -
-

${NOTICE}

- -
-
- -
- - -`; -const headers = (type, extra = {}) => ({ "content-type": type, ...extra }); +async function api(req, env, url) { + const store = env.KV || fallback; + const path = url.pathname; + if (path === "/api/health") return json({ ok: true, rev: env.REV || "", live: !!env.VENICE_API_KEY }); + if (req.method === "POST") { + const origin = req.headers.get("origin"); + if (origin && origin !== url.origin) return json({ error: "cross-site request" }, 403); + } + let visitor = visitorOf(req); + const setCookie = {}; + if (!visitor) { + visitor = crypto.randomUUID().replace(/-/g, ""); + setCookie["set-cookie"] = `${COOKIE}=${visitor}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${url.protocol === "https:" ? "; Secure" : ""}`; + } + const body = req.method === "POST" ? await req.json().catch(() => ({})) : {}; + + if (path === "/api/runs" && req.method === "POST") { + try { + const repo = String(body.repo || "").trim(), task = String(body.task || "").trim(); + const active = await store.get(`active:${visitor}`); + if (!active && !(await underCap(store, visitor, env))) return json({ error: "That's enough for today. The chair needs a rest; come back tomorrow." }, 429, setCookie); + const run = await createRun(store, { repo, task, visitor }); + return json({ id: run.id }, 201, setCookie); + } catch (e) { + if (e.code === "active") return json({ error: e.message, id: e.id }, 409, setCookie); + if (e.code === "invalid") return json({ error: Object.values(e.errors)[0], errors: e.errors }, 400, setCookie); + throw e; + } + } + + const m = path.match(/^\/api\/runs\/([a-z0-9]{6,16})(?:\/(step|safeword|say|patch))?$/); + if (!m) return json({ error: "not found" }, 404); + const [, id, action] = m; + const run = await getRun(store, id); + if (!run) return json({ error: "no such run" }, 404, setCookie); + const owner = run.visitor === visitor; + + if (!action && req.method === "GET") { + const after = Number(url.searchParams.get("after")) || 0; + const events = (await getEvents(store, id)).filter((e) => e.seq > after); + return json({ run: publicRun(run), events, owner }, 200, setCookie); + } + if (action === "patch" && req.method === "GET") { + const text = await patch(store, id); + return new Response(text, { headers: { "content-type": "text/x-diff; charset=utf-8", "content-disposition": `attachment; filename="quc-${id}.patch"` } }); + } + if (req.method !== "POST") return json({ error: "method not allowed" }, 405); + if (!owner) return json({ error: "Only the one in the chair can do that." }, 403, setCookie); + try { + if (action === "step") return json({ run: publicRun(await step(store, id, env)) }); + if (action === "safeword") return json({ run: publicRun(await safeword(store, id)) }); + if (action === "say") return json({ reply: await say(store, id, body.text) }); + } catch (e) { + if (e.code) return json({ error: e.message }, 400); + throw e; + } + return json({ error: "not found" }, 404); +} export default { async fetch(req, env = {}) { - const { pathname } = new URL(req.url); + const url = new URL(req.url); + try { + if (url.pathname.startsWith("/api/")) return await api(req, env, url); + } catch (e) { + return json({ error: "Something broke in the corner: " + String(e.message || e).slice(0, 200) }, 500); + } if (req.method !== "GET" && req.method !== "HEAD") return new Response("method not allowed", { status: 405 }); - if (pathname === "/api/health") return Response.json({ ok: true, rev: env.REV || "" }, { headers: { "cache-control": "no-store" } }); - if (pathname === "/chair.svg") return new Response(CHAIR, { headers: headers("image/svg+xml", { "cache-control": "public, max-age=3600" }) }); - if (pathname === "/" || pathname === "/index.html" || /^\/run\/[\w-]+$/.test(pathname)) return new Response(PAGE, { headers: headers("text/html; charset=utf-8") }); + if (url.pathname === "/chair.svg") return new Response(CHAIR, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } }); + if (url.pathname === "/" || url.pathname === "/index.html" || /^\/run\/[\w-]+$/.test(url.pathname)) + return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8", "x-frame-options": "DENY", "referrer-policy": "same-origin" } }); return new Response("not found", { status: 404 }); }, };