diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e07274..21ae16f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,4 @@ jobs: - run: python3 .github/time/test_time.py - run: python3 .github/time/test_seed.py - run: python3 .github/time/test_housekeeping.py + - run: npm test --prefix quc diff --git a/.github/workflows/quc.yml b/.github/workflows/quc.yml new file mode 100644 index 0000000..c6e64a2 --- /dev/null +++ b/.github/workflows/quc.yml @@ -0,0 +1,31 @@ +name: quc +# Test and deploy quc (quc/, live at quc.) when it changes on main. Skips the deploy until the Cloudflare +# token is staged as a repo secret (see .github/time/SECRETS.md); deploys from hb-5 work meanwhile. +on: + push: + branches: [main] + paths: ["quc/**"] + workflow_dispatch: +permissions: + contents: read +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 6 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || 'dac3b11890319628eb5cd761445b63ee' }} + VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }} + TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN || 'yolopreneur.lol' }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test --prefix quc + - name: deploy + if: env.CLOUDFLARE_API_TOKEN != '' + run: python3 quc/deploy.py + - name: no token yet + if: env.CLOUDFLARE_API_TOKEN == '' + run: echo "CLOUDFLARE_API_TOKEN is not staged; tests passed, deploy skipped" >> "$GITHUB_STEP_SUMMARY" diff --git a/quc/README.md b/quc/README.md index 68b06ac..53e9e18 100644 --- a/quc/README.md +++ b/quc/README.md @@ -1,33 +1,34 @@ # quc -App shell + API in one Cloudflare Worker (`src/worker.js`), live at https://quc.yolopreneur.lol/. No dependencies, no build step. +Hand an agent your repo, sit in the chair, watch. Live at https://quc.yolopreneur.lol/ (spec: teahubxyz/time#220). -- `npm start` serves it on :8787; `npm test` runs the tests; `npm run deploy` (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`) deploys and waits for `/api/health`. -- `GET /api/health` → `{ok, rev}`. `GET /chair.svg` is the chair-in-the-corner motif (also inlined in the shell). +One Cloudflare Worker, no dependencies, no build step. `npm start` serves it on :8787 (offline answers unless `VENICE_API_KEY` +is set), `npm test` runs the tests, `npm run deploy` deploys (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and +`VENICE_API_KEY` for real models) and waits for `/api/health`. -## Pipeline (needs a human: workers can't edit `.github/workflows`) +## How a run works -Add `.github/workflows/quc.yml`: +1. **Start**: a public GitHub URL and one line of task. One run at a time per visitor (a cookie), 5 runs a day each, 300 a + day in all (`RUNS_PER_DAY`, `RUNS_PER_DAY_ALL`). +2. **The cage**: quc downloads the repo's tarball from GitHub and keeps the readable text files (caps: 15 MB download, 2,000 + files, 40 KB a file, 4 files changed, 10 minutes). Nothing from the repo is executed, and nothing is pushed anywhere. +3. **Steps**: the chair page calls `POST /api/runs/:id/step` again and again; each call does one thing (clone, plan, rewrite + one file, wrap up), so every request stays short. A coding model (`CODER_MODEL`, default Qwen3 Coder via Venice) plans and + rewrites whole files; Grok (`PERSONA_MODEL`, default `grok-4-3` via Venice) narrates. Diffs, commits and commentary are + appended to the run's event log. +4. **The chair**: the page shows the commentary and the diffs, read-only. Only the owner sees the safeword and the box. +5. **Safeword**: ends the run at once and throws the edits away (the replay still shows what happened). +6. **Magic words** (typed in the box, matched loosely; the persona drops hints, so keep them out of marketing copy): + 1. "may I say something": notes. Anything typed after this is passed to the agent as a note. + 2. "not that one": vetoes. Type a file's path and the agent reverts and skips it. + 3. "my repo, my rules": takes the wheel. The run ends as `unlocked` and the patch is yours. + Anything else gets mocked. +7. **Replay**: the same URL, for anyone: a scrubber over every event, plus the aftermath card and the patch download. -```yaml -name: quc -on: - push: { branches: [main], paths: ["quc/**"] } - workflow_dispatch: -permissions: { contents: read } -jobs: - deploy: - runs-on: ubuntu-latest - timeout-minutes: 6 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 - with: { node-version: 22 } - - run: npm test --prefix quc - - run: python3 quc/deploy.py - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # still "missing" in SECRETS.md - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} -``` +Storage is a KV namespace (`time-quc`): `run:`, `events:`, `work:` (repo text and edits), `active:`, +`cap::*`, `lock:`. KV is eventually consistent and has no compare-and-set; a Durable Object per run is the upgrade +when that matters. -and, in `ci.yml`, `- run: npm test --prefix quc` so PRs run the quc tests. +## Pipeline + +`.github/workflows/quc.yml` deploys on pushes to `quc/**` on main, and `ci.yml` runs `npm test --prefix quc` on PRs. diff --git a/quc/deploy.py b/quc/deploy.py index 0cae32e..440c2e3 100644 --- a/quc/deploy.py +++ b/quc/deploy.py @@ -1,8 +1,9 @@ """Deploy quc to quc. (default yolopreneur.lol) as the Worker `time-quc`, then wait for /api/health. - CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... python3 quc/deploy.py + CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... VENICE_API_KEY=... python3 quc/deploy.py -Reuses .github/time/cloudflare.py. Idempotent: the script is replaced in place, the custom domain re-attached. +Reuses .github/time/cloudflare.py. Idempotent: the KV namespace `time-quc` is created once and reused, the script is replaced +in place, the custom domain re-attached. VENICE_API_KEY is bound as a Worker secret (without it the agent answers offline). """ from __future__ import annotations @@ -19,7 +20,7 @@ sys.path.insert(0, str(HERE.parent / ".github" / "time")) import cloudflare as cf # noqa: E402 NAME = "time-quc" -SRC = HERE / "src" / "worker.js" +MODULES = ["worker.js", "page.js", "runs.js", "engine.js", "agent.js", "diff.js", "validate.js"] def healthy(url: str, tries: int = 18) -> bool: @@ -34,16 +35,31 @@ def healthy(url: str, tries: int = 18) -> bool: return False +def kv_namespace(title: str) -> str: + for ns in cf.cf("GET", f"{cf.account()}/storage/kv/namespaces?per_page=100") or []: + if ns.get("title") == title: + return ns["id"] + return cf.cf("POST", f"{cf.account()}/storage/kv/namespaces", {"title": title})["id"] + + def deploy() -> dict: if not cf.enabled(): sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed") os.environ.setdefault("TIME_VENTURE_DOMAIN", "yolopreneur.lol") - code = SRC.read_bytes() - rev = hashlib.sha256(code).hexdigest()[:12] - meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT, - "bindings": [{"type": "plain_text", "name": "REV", "text": rev}]} - data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()), - ("worker.js", "application/javascript+module", code)]) + code = {m: (HERE / "src" / m).read_bytes() for m in MODULES} + rev = hashlib.sha256(b"".join(code[m] for m in MODULES)).hexdigest()[:12] + bindings = [{"type": "kv_namespace", "name": "KV", "namespace_id": kv_namespace(NAME)}, + {"type": "plain_text", "name": "REV", "text": rev}] + for k in ("CODER_MODEL", "PERSONA_MODEL", "RUNS_PER_DAY", "RUNS_PER_DAY_ALL"): + if os.environ.get(k): + bindings.append({"type": "plain_text", "name": k, "text": os.environ[k]}) + if os.environ.get("VENICE_API_KEY"): + bindings.append({"type": "secret_text", "name": "VENICE_API_KEY", "text": os.environ["VENICE_API_KEY"]}) + else: + print("warning: no VENICE_API_KEY, so the agent gives offline answers", file=sys.stderr) + meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT, "bindings": bindings} + data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode())] + + [(m, "application/javascript+module", code[m]) for m in MODULES]) cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype) return {"url": cf.attach(NAME, cf.host("quc")), "worker": NAME, "rev": rev} diff --git a/quc/dev.mjs b/quc/dev.mjs index a3520bc..79bc236 100644 --- a/quc/dev.mjs +++ b/quc/dev.mjs @@ -1,9 +1,16 @@ // Run the Worker locally: `npm start` in quc/, then http://localhost:8787/ +// Without VENICE_API_KEY in the environment the agent gives canned offline answers; with it, it uses the real models. import { createServer } from "node:http"; import worker from "./src/worker.js"; +import { memoryStore } from "./src/runs.js"; + +const env = { REV: "dev", KV: memoryStore(), VENICE_API_KEY: process.env.VENICE_API_KEY || "" }; createServer(async (q, s) => { - const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method }), { REV: "dev" }); + const chunks = []; + for await (const c of q) chunks.push(c); + const body = chunks.length && q.method !== "GET" && q.method !== "HEAD" ? Buffer.concat(chunks) : undefined; + const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method, headers: q.headers, body }), env); s.writeHead(r.status, Object.fromEntries(r.headers)); s.end(Buffer.from(await r.arrayBuffer())); -}).listen(process.env.PORT || 8787); +}).listen(process.env.PORT || 8787, () => console.log(`quc on http://localhost:${process.env.PORT || 8787}/`)); diff --git a/quc/package.json b/quc/package.json index 1136e98..ab189ba 100644 --- a/quc/package.json +++ b/quc/package.json @@ -1,9 +1,9 @@ { "name": "quc", - "version": "0.0.0", + "version": "0.1.0", "private": true, "type": "module", - "description": "quc.yolopreneur.lol: app shell + API in one Cloudflare Worker. No dependencies, no build step.", + "description": "quc.yolopreneur.lol: hand an agent your repo, sit in the chair, watch. One Cloudflare Worker, no dependencies, no build step.", "scripts": { "start": "node dev.mjs", "test": "node --test", diff --git a/quc/test/quc.test.js b/quc/test/quc.test.js new file mode 100644 index 0000000..bdbebad --- /dev/null +++ b/quc/test/quc.test.js @@ -0,0 +1,172 @@ +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import worker, { validate } from "../src/worker.js"; +import { memoryStore, createRun, getRun, getEvents } from "../src/runs.js"; +import { step, say, safeword, patch } from "../src/engine.js"; +import { untar, parseRepo } from "../src/agent.js"; +import { unified } from "../src/diff.js"; + +// ---------- a fake GitHub: a gzipped tarball built in memory ---------- + +function tar(files) { + const enc = new TextEncoder(), blocks = []; + for (const [path, body] of Object.entries(files)) { + const data = enc.encode(body), h = new Uint8Array(512); + h.set(enc.encode("repo-abc123/" + path).subarray(0, 100), 0); + h.set(enc.encode("0000644\0"), 100); + h.set(enc.encode(data.length.toString(8).padStart(11, "0") + "\0"), 124); + h[156] = 48; + h.set(enc.encode("ustar\0"), 257); + blocks.push(h, data, new Uint8Array((512 - (data.length % 512)) % 512)); + } + blocks.push(new Uint8Array(1024)); + const out = new Uint8Array(blocks.reduce((n, b) => n + b.length, 0)); + let i = 0; for (const b of blocks) { out.set(b, i); i += b.length; } + return out; +} +const gzip = async (bytes) => new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer()); +const REPO = { "README.md": "# demo\n\nA tiny repo.\n", "src/index.js": "export const x = 1;\n", "logo.png": "\0\0binary" }; +let tgz; +const github = async (url) => url.startsWith("https://codeload.github.com/") ? new Response(tgz, { headers: { "content-length": String(tgz.length) } }) : new Response("no", { status: 404 }); +beforeEach(async () => { tgz = await gzip(tar(REPO)); }); + +async function drive(store, id, n = 20) { + for (let i = 0; i < n; i++) { const r = await step(store, id, {}, github); if (r.status === "ended") return r; } + return getRun(store, id); +} + +// ---------- unit ---------- + +test("validate accepts a GitHub URL and a one-line task", () => { + assert.deepEqual(validate("https://github.com/a/b", "make it better please"), {}); + assert.ok(validate("https://gitlab.com/a/b", "make it better please").repo); + assert.ok(validate("https://github.com/a/b", "short").task); +}); + +test("parseRepo and untar read GitHub's tarball shape", async () => { + assert.deepEqual(parseRepo("https://github.com/o/n.git"), { owner: "o", name: "n" }); + const files = untar(tar({ "a.txt": "hi", "d/b.txt": "yo" })).map((f) => f.path); + assert.deepEqual(files, ["a.txt", "d/b.txt"]); +}); + +test("unified diff counts and marks lines", () => { + const d = unified("f", "a\nb\nc\n", "a\nB\nc\nd\n"); + assert.equal(d.added, 2); assert.equal(d.removed, 1); + assert.match(d.text, /^--- a\/f\n\+\+\+ b\/f\n@@ -1,3 \+1,4 @@/); + assert.equal(unified("f", "same\n", "same\n").text, ""); +}); + +// ---------- the run ---------- + +test("a run clones, plans, rewrites and finishes, and the patch has the change", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + const done = await drive(store, run.id); + assert.equal(done.status, "ended"); assert.equal(done.outcome, "finished"); + const kinds = (await getEvents(store, run.id)).map((e) => e.kind); + assert.ok(kinds.includes("diff") && kinds.includes("commit") && kinds.includes("commentary")); + assert.equal(done.stats.files, 1); + assert.match(await patch(store, run.id), /\+quc was here\./); + assert.equal(await store.get("active:v1"), null, "the visitor's slot is free again"); +}); + +test("one active run per visitor", async () => { + const store = memoryStore(); + await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + await assert.rejects(createRun(store, { repo: "https://github.com/o/demo", task: "another one please", visitor: "v1" }), { code: "active" }); +}); + +test("the safeword stops the run and throws the work away", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + await step(store, run.id, {}, github); + const r = await safeword(store, run.id); + assert.equal(r.outcome, "safeworded"); + assert.equal(await patch(store, run.id), ""); + assert.equal((await step(store, run.id, {}, github)).status, "ended", "a stopped run never moves again"); +}); + +test("talking from the chair: mocked until the phrases unlock notes, a veto, then the wheel", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + await step(store, run.id, {}, github); // cloned + await step(store, run.id, {}, github); // planned: README.md + await step(store, run.id, {}, github); // README.md rewritten (and finished, one file planned) + const r1 = await getRun(store, run.id); + assert.equal(r1.status, "ended"); + + const store2 = memoryStore(); + const run2 = await createRun(store2, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v2" }); + await step(store2, run2.id, {}, github); await step(store2, run2.id, {}, github); + assert.equal((await say(store2, run2.id, "hey stop")).kind, "commentary"); + assert.equal((await getRun(store2, run2.id)).level, 0); + await say(store2, run2.id, "May I say something?"); + assert.equal((await getRun(store2, run2.id)).level, 1); + await say(store2, run2.id, "please keep it short"); + assert.deepEqual((await getRun(store2, run2.id)).notes, ["please keep it short"]); + await say(store2, run2.id, "not that one!"); + await say(store2, run2.id, "README.md"); + const vetoed = await getRun(store2, run2.id); + assert.deepEqual(vetoed.vetoed, ["README.md"]); assert.deepEqual(vetoed.queue, []); + await say(store2, run2.id, "My repo, my rules."); + const end = await getRun(store2, run2.id); + assert.equal(end.status, "ended"); assert.equal(end.outcome, "unlocked"); +}); + +test("a veto after the edit reverts it", async () => { + const store = memoryStore(); + const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" }); + const r = await getRun(store, run.id); + await step(store, run.id, {}, github); await step(store, run.id, {}, github); + const live = await getRun(store, run.id); live.queue.push("src/index.js"); await store.put(`run:${run.id}`, JSON.stringify(live)); + await step(store, run.id, {}, github); // README.md edited, src/index.js still queued + assert.match(await patch(store, run.id), /README/); + await say(store, run.id, "may i say something"); await say(store, run.id, "not that one"); await say(store, run.id, "README.md"); + assert.equal(await patch(store, run.id), ""); + const ev = (await getEvents(store, run.id)).filter((e) => e.kind === "diff"); + assert.ok(ev.at(-1).data.reverted); + assert.ok(r); +}); + +// ---------- the Worker ---------- + +const call = (path, { method = "GET", body, cookie, origin } = {}) => worker.fetch(new Request("https://quc.test" + path, { + method, body: body ? JSON.stringify(body) : undefined, + headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}), ...(origin ? { origin } : {}) } }), env); +let env; +beforeEach(() => { env = { KV: memoryStore(), REV: "t" }; globalThis.fetch = github; }); + +test("health, page and chair render", async () => { + assert.deepEqual(await (await call("/api/health")).json(), { ok: true, rev: "t", live: false }); + const page = await (await call("/")).text(); + assert.match(page, /Hand over your repo/); assert.match(page, /function validate/); + assert.equal((await call("/run/abc123def0")).status, 200); + assert.match(await (await call("/chair.svg")).text(), / { + const res = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } }); + assert.equal(res.status, 201); + const cookie = res.headers.get("set-cookie").split(";")[0]; + const { id } = await res.json(); + assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {} })).status, 403, "a stranger can't drive"); + assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie, origin: "https://evil.test" })).status, 403, "no cross-site"); + for (let i = 0; i < 6; i++) await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie }); + const view = await (await call(`/api/runs/${id}`)).json(); + assert.equal(view.owner, false); assert.equal(view.run.status, "ended"); assert.equal(view.run.visitor, undefined); + assert.ok(view.events.length >= 4); + const p = await call(`/api/runs/${id}/patch`); + assert.match(await p.text(), /quc was here/); + const again = await (await call(`/api/runs/${id}?after=${view.events.length}`)).json(); + assert.equal(again.events.length, 0); +}); + +test("API: daily cap per visitor", async () => { + env.RUNS_PER_DAY = 1; + const first = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } }); + const cookie = first.headers.get("set-cookie").split(";")[0]; + const { id } = await first.json(); + await call(`/api/runs/${id}/safeword`, { method: "POST", body: {}, cookie }); + const second = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" }, cookie }); + assert.equal(second.status, 429); +}); diff --git a/quc/test/worker.test.js b/quc/test/worker.test.js index b536cdb..79def1e 100644 --- a/quc/test/worker.test.js +++ b/quc/test/worker.test.js @@ -1,33 +1,32 @@ import test from "node:test"; import assert from "node:assert/strict"; -import worker from "../src/worker.js"; +import worker, { validate } from "../src/worker.js"; -const get = (path, env, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env); +const get = (path, env = {}, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env); -test("health reports ok and the revision", async () => { +test("health reports ok, the revision, and whether real models are bound", async () => { const r = await get("/api/health", { REV: "abc" }); assert.equal(r.status, 200); - assert.deepEqual(await r.json(), { ok: true, rev: "abc" }); + assert.deepEqual(await r.json(), { ok: true, rev: "abc", live: false }); + assert.equal((await (await get("/api/health", { VENICE_API_KEY: "k" })).json()).live, true); }); -test("shell is responsive html that loads the motif", async () => { +test("the page is responsive html, the motif is an svg, unknown paths 404", async () => { const r = await get("/"); - const html = await r.text(); assert.match(r.headers.get("content-type"), /text\/html/); + const html = await r.text(); assert.match(html, /name="viewport" content="width=device-width/); - assert.match(html, /chair\.svg/); -}); - -test("motif is an svg, unknown paths 404, writes are refused", async () => { + assert.match(html, /
{ - const { validate } = await import("../src/worker.js"); +test("validate accepts a repo + task and rejects bad shapes", () => { const task = "Fix the flaky date test"; assert.deepEqual(validate("https://github.com/a/b", task), {}); assert.deepEqual(validate("https://github.com/a/b.git/", task), {}); @@ -37,13 +36,7 @@ test("validate accepts a repo + task and rejects bad shapes", async () => { assert.ok(validate("https://github.com/a/b", "x".repeat(201)).task); }); -test("start form, public-replay notice and run page are served; the inlined validator parses", async () => { - const html = await (await get("/")).text(); - assert.match(html, /
([\s\S]*)<\/script>/)[1]); // syntax check of the shipped script +test("the shipped script parses", async () => { + const html = await (await get("/run/abc123def0")).text(); + new Function(html.match(/